From 1b75d535f75113ccd60cb8accb8e4a83d235a134 Mon Sep 17 00:00:00 2001 From: said Date: Mon, 14 Sep 2026 05:37:58 +0100 Subject: [PATCH 01/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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/96] 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) From 349ea0d6cbd4c52a2883f96a0af18515e8d30c04 Mon Sep 17 00:00:00 2001 From: said Date: Wed, 16 Sep 2026 01:17:01 +0100 Subject: [PATCH 25/96] Follow a published name to its declaration, and publish only what a contract states Four corrections to the re-export and naming work. A name may be published by a module that was published a name in turn, and reading only the module a `use` names stopped at the first hop. The entity then looked like nothing at all and its re-export was dropped, so a twice-published procedure reached Python through no namespace. Each hop is followed now, through renames and plain `use` alike, until the declaration itself is reached; a cycle ends the search rather than circling it. A contract's imports were all read as exports, so an import written to express a declaration became a Python name after a source-to-contract round trip and the two builds disagreed about what a module publishes. A re-export is stated by aliasing the name to itself, the way a stub marks anything it re-exports, and a plain import states only what the contract needed to name. Both still reach the export tree, because a contract reading from this one has to resolve what it names; only the published ones become attributes. A package entry contract is the exception it has always been: it declares little and exists to choose a surface, so the names it imports are the ones it means to publish. A generic declared inside a procedure belongs to that procedure. The parser stopped merging two such blocks but reported them as the module's own, so later stages read them as module generics of one name. The declaring scope is recorded and module generics are read from module scope alone. Two names were still derived rather than read. A prototype identity was matched exactly, so a `use` differing only in case asked for a spelling no contract declares; it is matched without regard to case and answers with the spelling its contract uses. An import and an overload target were normalized independently of the module that named the declaration, which a collision can move aside: a module holding `lambda` and `lambda_` publishes `lambda_` and `lambda__2`, and publishing the second silently bound the first. Each module is named once before any import is written, and a source spelling identifies a declaration ahead of a published one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 15 +++ prik/parsers/fortran/models.py | 11 ++ prik/parsers/fortran/parser.py | 18 ++- prik/pipeline/build.py | 36 +++++- prik/pipeline/pyi.py | 9 +- prik/planning/planner.py | 11 +- prik/printers/pyi.py | 116 ++++++++++++----- prik/semantics/fortran2ir.py | 64 +++++++-- prik/semantics/models.py | 10 +- .../test_fortran_generic_semantics.py | 38 ++++++ .../end_to_end/test_multi_source_builds.py | 7 +- .../scope_name_reuse_combinations.json | 12 +- .../test_pyi_printer_imports_and_packages.py | 122 ++++++++++++++++++ .../test_module_variables_and_state.py | 67 ++++++++++ 14 files changed, 481 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 456bb8810..66a22afa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ release tags add a leading `v` to the package version. ## Unreleased +- A published name is followed to the module declaring it, however many + modules published it along the way. Reading only the module a `use` names + left a name published twice over looking like nothing at all, and the + re-export was dropped. + +- A generated contract states a re-export by aliasing the name to itself, the + way a stub marks anything it publishes, so an import written to express a + declaration is no longer republished. Source and contract builds agree on + what a module exports; a package entry contract still selects its surface by + importing, which is what such a contract is for. + +- A generic declared inside a procedure is no longer read as one of its + module's own, and an import binds the name a collision made its declaring + contract use rather than one derived from the source spelling. + - 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 diff --git a/prik/parsers/fortran/models.py b/prik/parsers/fortran/models.py index 3279e1141..4b634ecee 100644 --- a/prik/parsers/fortran/models.py +++ b/prik/parsers/fortran/models.py @@ -367,6 +367,17 @@ class FortranInterface: specific_procedures: list[str] = field(default_factory=list) abstract: bool = False + declaring_scope_kind: str = "module" + """Kind of scope declaring this block: file, module, submodule or procedure.""" + + declaring_scope_path: list[str] = field(default_factory=list) + """Names of the scopes enclosing this block, outermost first. + + A generic belongs to the scope declaring it, so a block written inside a + procedure names a generic of that procedure and not of its module. Keeping + the owner lets later stages read only the generics a module itself declares. + """ + @dataclass class FortranEnumerator: diff --git a/prik/parsers/fortran/parser.py b/prik/parsers/fortran/parser.py index 9bb1d2147..532413a64 100644 --- a/prik/parsers/fortran/parser.py +++ b/prik/parsers/fortran/parser.py @@ -1940,10 +1940,7 @@ 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._interface_scope_identity(scope), - ) + self._interface_with_scope(unit, scope, filename) for unit, scope in self._collect_interface_source_units(lines, filename) ] ) @@ -1957,6 +1954,19 @@ def _helper_attach_file_interfaces( ] return [iface for iface in interfaces if iface.module is None] + def _interface_with_scope( + self, + unit: SourceUnit, + scope: _ParserScope, + filename: str | None, + ) -> tuple[FortranInterface, tuple[tuple[str, str], ...]]: + """Parse one interface block and record the scope that declares it.""" + interface = self._visit(unit, parent_scope=scope, filename=filename) + identity = self._interface_scope_identity(scope) + interface.declaring_scope_kind = identity[-1][0] if identity else "file" + interface.declaring_scope_path = [name for _kind, name in identity if name] + return interface, identity + @staticmethod def _interface_scope_identity(scope: _ParserScope | None) -> tuple[tuple[str, str], ...]: """Return the lexical scope chain that owns one interface block. diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index ac3e2f41d..4fa6b4f32 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -2047,6 +2047,14 @@ class _PyiExportNode: declarations: list[object] = field(default_factory=list) children: dict[str, _PyiExportNode] = field(default_factory=dict) origins: set[Path] = field(default_factory=set) + unpublished: set[str] = field(default_factory=set) + """Names this node resolves for its own declarations but does not export. + + An import states what a contract needs to express its declarations, which + is not the same as a name it means to publish. Both reach the tree, because + a contract reading from this one still has to resolve what it names, and + only a published name becomes a Python attribute here. + """ def _apply_pyi_python_exports(entry: Path, modules_by_path: dict[Path, SemanticModule]) -> None: @@ -2062,7 +2070,11 @@ def _apply_pyi_python_exports(entry: Path, modules_by_path: dict[Path, SemanticM for declaration in _module_declarations(module): _set_declaration_exports(declaration, []) - tree = _pyi_export_tree(entry, modules_by_path, cache={}, pending=set()) + # The entry contract is the package's own surface: it declares little and + # exists to choose what the package exports, so the names it imports are + # the ones it means to publish. A module contract imports what it needs to + # express its declarations, which is not the same intent. + tree = _pyi_export_tree(entry, modules_by_path, cache={}, pending=set(), publishes_imports=True) _record_pyi_exports(tree) for module in modules_by_path.values(): for declaration in module.classes: @@ -2088,6 +2100,7 @@ def _pyi_export_tree( path: Path, modules_by_path: dict[Path, SemanticModule], *, + publishes_imports: bool = False, cache: dict[Path, _PyiExportNode], pending: set[Path], ) -> _PyiExportNode: @@ -2124,7 +2137,15 @@ def _pyi_export_tree( for semantic_import in module.imports: if not isinstance(semantic_import, SemanticImport) or not semantic_import.module.startswith("."): continue - _merge_relative_import(tree, path, semantic_import, modules_by_path, cache, pending) + _merge_relative_import( + tree, + path, + semantic_import, + modules_by_path, + cache, + pending, + publishes_imports=publishes_imports, + ) pending.remove(path) cache[path] = tree return tree @@ -2137,6 +2158,7 @@ def _merge_relative_import( modules_by_path: dict[Path, SemanticModule], cache: dict[Path, _PyiExportNode], pending: set[Path], + publishes_imports: bool = False, ) -> None: """Merge one relative import's exports into the current namespace tree. @@ -2156,7 +2178,13 @@ def _merge_relative_import( continue if item.source not in dependency_tree.children: raise ValueError(f"Imported semantic name {item.source!r} not found in {dependency}") - _merge_export_child(tree, item.target or item.source, dependency_tree.children[item.source], origin=path) + local = item.target or item.source + _merge_export_child(tree, local, dependency_tree.children[item.source], origin=path) + if item.target is None and not publishes_imports: + # A plain import names what this contract needs to express its + # own declarations. Re-export is stated by aliasing the name + # explicitly, as a stub does for anything it means to publish. + tree.unpublished.add(local) return for item in semantic_import.items: @@ -2213,6 +2241,8 @@ def _record_pyi_exports(tree: _PyiExportNode, namespace: tuple[str, ...] = ()) - The declaration metadata is intentionally mutated for later planning. """ for name, child in tree.children.items(): + if name in tree.unpublished: + continue for declaration in child.declarations: if isinstance(declaration, SemanticPrototype): continue diff --git a/prik/pipeline/pyi.py b/prik/pipeline/pyi.py index 68c53f9ec..44a151e80 100644 --- a/prik/pipeline/pyi.py +++ b/prik/pipeline/pyi.py @@ -16,7 +16,7 @@ from prik.parsers.pyi import parse_pyi_text from prik.policy.completion import complete_semantic_policies -from prik.printers.pyi import emit_module +from prik.printers.pyi import PyiPrinter, emit_module from prik.semantics.models import EXTERNAL_TYPE_REF_METADATA, SemanticClass, SemanticModule, _module_semantic_types from prik.semantics.pyi_metadata import PYI_LOADED_METADATA from prik.semantics.pyi2ir import convert_pyi_to_ir, reconcile_external_type_refs @@ -146,11 +146,18 @@ def emit_module_stubs( for reexport in module.reexports if reexport.entity_kind == "prototype" } + # What a contract publishes a name under is settled by rendering it, so + # every module is named once before any of them writes an import. + naming_printer = PyiPrinter(normalize_fortran_public_names=normalize_fortran_public_names) + published_names_by_module = { + module_name: naming_printer.published_names(module) for module_name, module in emitted_modules.items() + } return { module_name: emit_module( module, normalize_fortran_public_names=normalize_fortran_public_names, declared_prototype_names=declared_prototype_names, + published_names_by_module=published_names_by_module, ).strip() for module_name, module in emitted_modules.items() } diff --git a/prik/planning/planner.py b/prik/planning/planner.py index 9baa4491f..bd4d5326c 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -585,6 +585,7 @@ def _exported_declaration_name( exports no such object and there is nothing an alias could bind. """ wanted = source_name.casefold() + published: str | None = None for declaration in (*module.functions, *module.classes): if getattr(declaration, "visibility", "public") != "public": continue @@ -594,9 +595,15 @@ def _exported_declaration_name( name = export.get("name") if not name or tuple(export.get("namespace") or ()) != namespace: continue - if native == wanted or str(name).casefold() == wanted: + # A source spelling identifies the declaration itself, while a + # published one identifies what a namespace called it. Only a + # collision makes the two name different declarations, and then + # the source spelling is the one that came from Fortran. + if native == wanted: return str(name) - return None + if str(name).casefold() == wanted: + published = published or str(name) + return published def _namespace_plan( self, diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index e0068d39e..7321a4568 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -96,6 +96,13 @@ class _PyiEmissionContext: naming_policy: NamingPolicy = field(default_factory=NamingPolicy) reserved_public_names: dict[tuple[tuple[str, ...], str, object], str] = field(default_factory=dict) public_namespace: tuple[str, ...] = () + published_names: dict[str, str] = field(default_factory=dict) + """Source name, casefolded, to the spelling this contract published it under. + + Collision handling can move a name aside, so what a declaration is finally + called is knowable only from the emission that named it. A module reading + from this one asks for the published spelling rather than deriving one. + """ def contract(self, name: str) -> str: """Return one local contract spelling and record its required import.""" @@ -127,6 +134,8 @@ def public_name(self, raw_name: str, *, category: str, owner: object) -> str: owner=raw_name, ) self.reserved_public_names[key] = public_name + if not self.public_namespace: + self.published_names.setdefault(str(raw_name).casefold(), public_name) return public_name def contract_import(self) -> str: @@ -167,6 +176,7 @@ def __init__( *, normalize_fortran_public_names: bool = False, declared_prototype_names: Iterable[tuple[str, str]] = (), + published_names_by_module: dict[str, dict[str, str]] | None = None, ): """Configure public-name normalization for independent emissions. @@ -179,9 +189,12 @@ def __init__( module may spell an ordinary declaration the same way. """ self._normalize_fortran_public_names = normalize_fortran_public_names - self._declared_prototype_names = frozenset( - (str(module).casefold(), str(name)) for module, name in declared_prototype_names - ) + self._declared_prototype_names = { + (str(module).casefold(), str(name).casefold()): str(name) for module, name in declared_prototype_names + } + self._published_names_by_module = { + str(module).casefold(): dict(names) for module, names in (published_names_by_module or {}).items() + } def emit(self, node) -> str: """Render one supported semantic model to semantic .pyi text. @@ -194,6 +207,23 @@ def emit(self, node) -> str: context = self._emission_context(node) return self._visit(node, context) + def published_names(self, module: SemanticModule) -> dict[str, str]: + """Return the spelling this module's contract publishes each name under. + + Rendering is what settles a name, because a collision can move one + aside, so the module is rendered and only its naming kept. A prototype + is published as it is declared and never renamed. + """ + context = self._emission_context(module) + self._visit(module, context) + names = dict(context.published_names) + for prototype in module.prototypes: + names[str(prototype.name).casefold()] = str(prototype.name) + for reexport in module.reexports: + if reexport.entity_kind == "prototype": + names[str(reexport.local_name).casefold()] = str(reexport.local_name) + return names + def _emission_context(self, node) -> _PyiEmissionContext: """Build isolated state for one public emission call.""" if not isinstance(node, SemanticModule): @@ -396,7 +426,11 @@ def _overload_target_name(candidate: SemanticFunction, context: _PyiEmissionCont 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 + # The specific was named while this same contract was rendered, and a + # collision may have moved that name aside, so the naming it settled on + # is what the target has to state. + published = context.published_names.get(target.casefold()) + return published or normalize_public_name(target).name def _visit_ProcedureOverloadSet( self, @@ -1490,6 +1524,8 @@ def _append_imports( native_source=not module.metadata.get(PYI_LOADED_METADATA), public_names=context.normalize_fortran_public_names, verbatim_names=verbatim, + published_names_by_module=self._published_names_by_module, + reexported=frozenset(str(item.local_name).casefold() for item in module.reexports), ) ) if contract_import or imports: @@ -1787,7 +1823,9 @@ def _emit_import( *, native_source: bool = False, public_names: bool = False, - verbatim_names: frozenset[tuple[str, str]] = frozenset(), + verbatim_names: dict[tuple[str, str], str] | None = None, + published_names_by_module: dict[str, dict[str, str]] | None = None, + reexported: frozenset[str] = frozenset(), ) -> str: """Emit import syntax.""" if isinstance(imp, str): @@ -1795,12 +1833,15 @@ def _emit_import( if not imp.items: return f"import {imp.module}" source_module = imp.module.lstrip(".").casefold() + published_names = (published_names_by_module or {}).get(source_module) items = ", ".join( PyiPrinter._emit_import_item( item, public_names=public_names, verbatim_names=verbatim_names, source_module=source_module, + published_names=published_names, + reexported=reexported, ) for item in imp.items ) @@ -1812,8 +1853,10 @@ def _emit_import_item( item: SemanticImportItem, *, public_names: bool = False, - verbatim_names: frozenset[tuple[str, str]] = frozenset(), + verbatim_names: dict[tuple[str, str], str] | None = None, source_module: str = "", + published_names: dict[str, str] | None = None, + reexported: frozenset[str] = frozenset(), ) -> str: """Emit import item syntax. @@ -1828,24 +1871,28 @@ def _emit_import_item( # 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) + declared = verbatim_names or {} + local = item.target or item.source + prototype = declared.get((source_module, item.source.casefold())) or declared.get( + (source_module, local.casefold()) + ) + if prototype is not None: + # A prototype keeps its declared spelling on both sides, because an + # annotation naming it is written exactly that way. + return prototype if local == prototype else f"{prototype} as {local}" source = PyiPrinter._public_import_name(item.source, public_names=public_names) + if public_names and published_names: + source = published_names.get(item.source.casefold(), source) target = PyiPrinter._public_import_name(item.target, public_names=public_names) if target and target != source: return f"{source} as {target}" + if local.casefold() in reexported: + # Publishing an imported name is stated by aliasing it explicitly, + # so a contract reading this one can tell a re-export from an import + # written only to express a declaration. + return f"{source} as {source}" 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.""" @@ -1853,29 +1900,37 @@ 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[tuple[str, str]]: - """Return prototype identities a contract writes under declared spelling. + def _verbatim_import_names(self, module: SemanticModule) -> dict[tuple[str, str], str]: + """Map prototype identities to the spelling their contract declares. 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. 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. + Fortran reaches a name without regard to case, so the identity is + matched that way and the declared spelling is what the mapping returns. 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((module.name.casefold(), str(prototype.name)) for prototype in module.prototypes) + names = dict(self._declared_prototype_names) + for prototype in module.prototypes: + names[(module.name.casefold(), str(prototype.name).casefold())] = str(prototype.name) 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") + declared_name = reference.get("name") or local_name origin = reference.get("origin_module") if local_name and origin: - names.add((str(origin).casefold(), str(local_name))) - return frozenset(names) + # Both spellings identify the same prototype, and each maps to + # the one its declaring contract writes. + scope = str(origin).casefold() + names.setdefault((scope, str(local_name).casefold()), str(declared_name)) + names.setdefault((scope, str(declared_name).casefold()), str(declared_name)) + return names def _append_items(self, sections: list[str], items: list, emit_item) -> None: """Append items.""" @@ -2736,20 +2791,23 @@ def emit_module( module: SemanticModule, *, normalize_fortran_public_names: bool = False, - declared_prototype_names: Iterable[str] = (), + declared_prototype_names: Iterable[tuple[str, str]] = (), + published_names_by_module: dict[str, dict[str, str]] | None = None, ) -> 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, and declared_prototype_names to name the prototypes the - modules rendered alongside this one declare. Both paths create a fresh - module emission context. + public names, declared_prototype_names to name the prototypes the modules + rendered alongside this one declare, and published_names_by_module to state + the spelling each of those modules published its names under. Every path + creates a fresh module emission context. """ - if normalize_fortran_public_names or declared_prototype_names: + if normalize_fortran_public_names or declared_prototype_names or published_names_by_module: return PyiPrinter( normalize_fortran_public_names=normalize_fortran_public_names, declared_prototype_names=declared_prototype_names, + published_names_by_module=published_names_by_module, ).emit(module) return _DEFAULT_PRINTER.emit(module) diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 7a56fd969..70058436d 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -1564,18 +1564,59 @@ def _module_reexports( named.add(local_name.casefold()) if local_name.casefold() in declared or local_name.casefold() not in published: continue + kind, origin_module, origin_name = cls._resolve_reexport_origin(index, module_name, mapping.source) reexports.append( SemanticReexport( local_name, - module_name, - mapping.source, + origin_module, + origin_name, module.name, - entity_kind=cls._reexported_entity_kind(index.get(module_name.casefold()), mapping.source), + entity_kind=kind, ) ) reexports.extend(cls._wildcard_reexports(module, index, declared=declared, published=published, named=named)) return reexports + @classmethod + def _resolve_reexport_origin( + cls, + index: dict[str, FortranModule], + module_name: str, + source_name: str, + seen: frozenset[tuple[str, str]] = frozenset(), + ) -> tuple[str, str, str]: + """Return where a published name is declared, following every hop. + + A module may publish a name it imported from a module that published it + in turn, so the module a ``use`` reads is not always the one declaring + the entity. Following the chain reports the declaration itself: its + kind, the module holding it, and the name it is declared under. A name + reached through no declaration, or through a cycle, stays unknown. + """ + key = (module_name.casefold(), source_name.casefold()) + declaring = index.get(module_name.casefold()) + if declaring is None or key in seen: + return "unknown", module_name, source_name + kind = cls._declared_entity_kind(declaring, source_name) + if kind != "unknown": + return kind, declaring.name, source_name + seen = seen | {key} + for used_name, mappings in declaring.uses.items(): + for mapping in mappings: + if mapping.local_name.casefold() == source_name.casefold(): + return cls._resolve_reexport_origin(index, used_name, mapping.source, seen) + # A `use` naming no list carries every public name of what it reads. + resolved = [ + origin + for used_name, mappings in declaring.uses.items() + if not mappings + for origin in (cls._resolve_reexport_origin(index, used_name, source_name, seen),) + if origin[0] != "unknown" + ] + if len(resolved) == 1: + return resolved[0] + return "unknown", module_name, source_name + @classmethod def _wildcard_reexports( cls, @@ -1606,17 +1647,20 @@ def _wildcard_reexports( 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" + origin + for used in wildcard + for origin in (cls._resolve_reexport_origin(index, used.name, name),) + if origin[0] != "unknown" ] if len(origins) != 1: continue - used, kind = origins[0] - reexports.append(SemanticReexport(name, used.name, name, module.name, entity_kind=kind)) + kind, origin_module, origin_name = origins[0] + reexports.append(SemanticReexport(name, origin_module, origin_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.""" + def _declared_entity_kind(declaring: FortranModule | None, source_name: str) -> str: + """Return what one name declares in the module that holds its declaration.""" if declaring is None: return "unknown" key = source_name.casefold() @@ -2662,6 +2706,10 @@ def _module_overload_sets( for interface in module.interfaces: if not interface.name or interface.abstract: continue + if interface.declaring_scope_kind == "procedure": + # A generic written inside a procedure belongs to that + # procedure, so it is never part of the module's own interface. + continue inline_lookup = { signature.name.casefold(): self.visit( signature, diff --git a/prik/semantics/models.py b/prik/semantics/models.py index aa0d6c237..12e73f87c 100644 --- a/prik/semantics/models.py +++ b/prik/semantics/models.py @@ -690,11 +690,11 @@ class SemanticReexport: """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. + Python object, which today means an ordinary procedure or a derived type. + Every other kind -- a callback prototype, a module variable whose state + stays live, 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. """ 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 bf4e15567..bc2b4ff6c 100644 --- a/tests/fortran/generic_interfaces/semantics/test_fortran_generic_semantics.py +++ b/tests/fortran/generic_interfaces/semantics/test_fortran_generic_semantics.py @@ -210,3 +210,41 @@ def test_type_bound_generic_split_across_statements_reaches_one_overload_set(): assert [(item.name, [proc.name for proc in item.procedures]) for item in shape.overload_sets] == [ ("area", ["area_integer", "area_real"]) ] + + +def test_a_generic_declared_inside_a_procedure_is_not_a_module_generic(): + """A generic belongs to the scope declaring it, and a procedure is a scope. + + An interface written inside a procedure names a generic of that procedure. + Reading it as one of the module's own would publish it, and two procedures + naming one generic would each answer for the other. + """ + 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 = FortranToIRConverter().visit(parse_fortran_source(source).modules[0]) + + assert module.overload_sets == [] 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 48d110b1f..bbe9df0e6 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 @@ -332,7 +332,12 @@ 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 + # `box_ops` imports the type to express its own signature and publishes no + # name of its own, so neither route adds one. The type stays where it is + # declared, and both builds agree on that. + assert not hasattr(generated_module.box_ops, "box") + assert not hasattr(source_module.box_ops, "box") + assert generated_module.shared_types.box is not None def test_generated_module_leaf_loads_sibling_type_contract(tmp_path: Path): diff --git a/tests/fortran/infrastructure/parsing/fixtures/general/scope_name_reuse_combinations.json b/tests/fortran/infrastructure/parsing/fixtures/general/scope_name_reuse_combinations.json index 4b3cfcfeb..7df4f2e5f 100644 --- a/tests/fortran/infrastructure/parsing/fixtures/general/scope_name_reuse_combinations.json +++ b/tests/fortran/infrastructure/parsing/fixtures/general/scope_name_reuse_combinations.json @@ -504,7 +504,11 @@ "do_work_r", "do_work_l" ], - "abstract": false + "abstract": false, + "declaring_scope_kind": "module", + "declaring_scope_path": [ + "scope_name_reuse_combinations" + ] } ], "enums": [], @@ -1024,7 +1028,11 @@ "do_work_r", "do_work_l" ], - "abstract": false + "abstract": false, + "declaring_scope_kind": "module", + "declaring_scope_path": [ + "scope_name_reuse_combinations" + ] } ], "enums": [], 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 0929d4e81..c89c6a1da 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 @@ -771,3 +771,125 @@ def test_prototype_spelling_is_kept_only_for_the_module_that_declares_one(): 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"] + + +def test_prototype_import_uses_the_declared_spelling_whatever_case_names_it(): + """Fortran reaches a prototype without regard to case; a contract does not. + + A module may write `use callback_mod, only : obj` for a prototype declared + as `OBJ`, and the annotation then names it that way. The import binds the + declared spelling under the name this contract uses. + """ + callbacks = parse_fortran_source(""" +module callback_mod +implicit none +public :: OBJ +abstract interface +subroutine OBJ(x) +implicit none +real(8), intent(in) :: x +end subroutine OBJ +end interface +end module callback_mod +""") + user = parse_fortran_source(""" +module user_mod +use callback_mod, only : obj +implicit none +contains +subroutine run(f, v) +procedure(obj) :: f +real(8), intent(in) :: v +end subroutine run +end module user_mod +""") + + stubs = emit_module_stubs( + [fortran_module_to_semantic_module(item) for item in (callbacks, user)], + normalize_fortran_public_names=True, + ) + + assert "def OBJ(" in stubs["callback_mod"] + assert "from .callback_mod import OBJ as obj" in stubs["user_mod"] + assert "f: obj" in stubs["user_mod"] + + +def test_import_binds_the_name_a_collision_made_the_declaring_contract_use(): + """A collision moves a name aside, and the import follows it there.""" + home = parse_fortran_source(""" +module collide_home +implicit none +contains +subroutine lambda(x) +integer, intent(inout) :: x +end subroutine lambda +subroutine lambda_(x) +integer, intent(inout) :: x +end subroutine lambda_ +end module collide_home +""") + user = parse_fortran_source(""" +module collide_user +use collide_home, only : lambda_ +implicit none +private +public :: lambda_ +end module collide_user +""") + + stubs = emit_module_stubs( + [fortran_module_to_semantic_module(item) for item in (home, user)], + normalize_fortran_public_names=True, + ) + + assert "def lambda__2(" in stubs["collide_home"] + assert "from .collide_home import lambda__2 as lambda_" in stubs["collide_user"] + + +def test_a_contract_states_a_reexport_by_aliasing_the_name_it_publishes(): + """An import expresses a declaration; an alias publishes a name. + + A module publishing an imported entity writes it aliased to itself, the way + a stub marks anything it re-exports, so a contract reading this one can tell + the two apart. An import written only to name a type states no such intent. + """ + home = parse_fortran_source(""" +module publish_home +implicit none +type :: box +integer :: value +end type box +contains +subroutine scale_value(x) +integer, intent(inout) :: x +end subroutine scale_value +end module publish_home +""") + facade = parse_fortran_source(""" +module publish_facade +use publish_home, only : scale_value +implicit none +private +public :: scale_value +end module publish_facade +""") + consumer = parse_fortran_source(""" +module publish_consumer +use publish_home, only : box +implicit none +contains +integer function box_value(item) result(out) +type(box), intent(in) :: item +out = item%value +end function box_value +end module publish_consumer +""") + + stubs = emit_module_stubs( + [fortran_module_to_semantic_module(item) for item in (home, facade, consumer)], + normalize_fortran_public_names=True, + ) + + assert "from .publish_home import scale_value as scale_value" in stubs["publish_facade"] + assert "from .publish_home import box\n" in stubs["publish_consumer"] + assert "box as box" not in stubs["publish_consumer"] 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 3085a7d36..4878fe72f 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 @@ -590,6 +590,33 @@ def test_declared_length_character_module_arrays_compile_and_expose_their_width( private public :: scale_value end module reexport_wildcard_mod + +module reexport_hop_mod + use reexport_facade_mod, only : scale_value + implicit none + private + public :: scale_value +end module reexport_hop_mod + +module reexport_collide_mod + implicit none +contains + subroutine lambda(x) + integer, intent(inout) :: x + x = x + 1 + end subroutine lambda + subroutine lambda_(x) + integer, intent(inout) :: x + x = x + 100 + end subroutine lambda_ +end module reexport_collide_mod + +module reexport_collide_user_mod + use reexport_collide_mod, only : lambda_ + implicit none + private + public :: lambda_ +end module reexport_collide_user_mod """ @@ -671,3 +698,43 @@ def test_publishing_a_name_a_plain_use_brought_in_republishes_only_that_name(tmp 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) + + +def test_publishing_an_already_published_import_follows_it_to_its_declaration(tmp_path: Path): + """A published name may come from a module that published it in turn. + + The module a `use` reads is not always the one declaring the entity, so + each hop is followed until the declaration itself is reached; stopping at + the first module leaves the name looking like nothing 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_hop_mod.scale_value is module.reexport_home_mod.scale_value + assert module.reexport_hop_mod.scale_value(np.int32(7)) == np.int32(14) + + +def test_published_import_binds_the_declaration_a_collision_moved_aside(tmp_path: Path): + """Two source names may want one Python name, and only one may have it. + + A module holding both `lambda` and `lambda_` publishes them as `lambda_` + and `lambda__2`, so a module publishing the second reaches the name the + declaring module settled on rather than the one its source resembles. + """ + 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_collide_mod.lambda_(np.int32(0)) == np.int32(1) + assert module.reexport_collide_mod.lambda__2(np.int32(0)) == np.int32(100) + assert module.reexport_collide_user_mod.lambda_ is module.reexport_collide_mod.lambda__2 + assert module.reexport_collide_user_mod.lambda_(np.int32(0)) == np.int32(100) From 8b9fc7f60f4071bfc9f94ad5e456eefc610312a4 Mon Sep 17 00:00:00 2001 From: said Date: Wed, 16 Sep 2026 03:23:46 +0100 Subject: [PATCH 26/96] State what a contract publishes in its own __all__ An import cannot say whether a name is needed to express a declaration or meant to be published. `use shapes_mod, only : crate => box` reads the same whether the rename avoids a collision or publishes the type under a new name, so no import spelling can carry the difference. A contract now closes with `__all__`, naming its whole public surface. PRIK writes what the source publishes: a module's own public declarations, and any imported name a `public` statement names. The list exists to be edited. Removing a name leaves the entity declared and reachable from other contracts while it stops reaching Python; adding an imported one publishes it here, including a name imported only to express a declaration; an empty list publishes nothing. A contract stating no list has named no surface, so everything it reaches is published, which is what an entry contract selecting from its package has always meant. Every name reaches the export tree either way, because a contract reading from this one still has to resolve what it names. Only a published name becomes an attribute. Names that reach Python by other means keep to them: a sub-namespace attaches the package tree rather than binding an entity, and an intrinsic module has no contract to read a name from. A re-exported procedure also binds the callable its declaring module exported rather than being wrapped again, so a contract build gives the same object a source build does, renamed re-exports included. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 12 + docs/developer/packages/printers.md | 5 +- docs/user/reference/pyi-format.md | 33 + prik/cli.py | 36 +- prik/pipeline/build.py | 77 +- prik/printers/pyi.py | 115 +- prik/semantics/models.py | 9 + prik/semantics/pyi2ir.py | 15 + .../pyi/general/basic_array_update.pyi | 2 + .../pyi/general/c_richer_features.pyi | 14 + tests/c/fixtures/pyi/general/constants.pyi | 12 + tests/c/fixtures/pyi/general/math_api.pyi | 2 + tests/c/fixtures/pyi/general/mesh.pyi | 2 + .../pyi/general/modern_math_physics.pyi | 12 + tests/c/fixtures/pyi/general/name_reuse.pyi | 15 + tests/c/fixtures/pyi/general/particles.pyi | 2 + tests/c/fixtures/pyi/general/shape_exprs.pyi | 12 + .../end_to_end/test_c_magic_runtime.py | 2 + .../semantics/test_c_record_semantics.py | 1 + .../fallocatable_views_f90.pyi | 18 + .../fscalar_allocatables_f90.pyi | 12 + .../allocatables_direct_bind_c_f90.pyi | 2 + .../allocatables_mixed_bind_c_f90.pyi | 2 + .../contracts/array_ops/array_ops.pyi | 13 + .../farray_contracts_f90.pyi | 23 + .../farray_results_f90/farray_results_f90.pyi | 28 + .../fassumed_rank_f90/fassumed_rank_f90.pyi | 2 + .../contracts/fmath_arrays/__init__.pyi | 88 + .../fmath_arrays_f90/fmath_arrays_f90.pyi | 173 + .../contracts/multid_arrays/multid_arrays.pyi | 10 + .../arrays_direct_bind_c_f90.pyi | 2 + .../arrays_mixed_bind_c_f90.pyi | 2 + .../fcallback_all_f90/fcallback_all_f90.pyi | 14 + .../fcallback_array_f90.pyi | 9 + .../fcallback_scalar_f90.pyi | 2 + .../callbacks_direct_bind_c_f90.pyi | 2 + .../callbacks_mixed_bind_c_f90.pyi | 2 + .../fbind_value_f90/fbind_value_f90.pyi | 10 + .../fixtures/contracts/fmath/__init__.pyi | 88 + .../contracts/fmath_f90/fmath_f90.pyi | 88 + .../fscalar_kinds_f90/fscalar_kinds_f90.pyi | 22 + .../scalar_direct_bind_c_f90.pyi | 2 + .../scalar_mixed_bind_c_f90.pyi | 2 + .../fbind_c_derived_layout_f90.pyi | 2 + .../fborrowed_finalizer_f90.pyi | 2 + .../contracts/fclasses_f90/fclasses_f90.pyi | 13 + .../fconstructors_f90/fconstructors_f90.pyi | 2 + .../fderived_boundary_f90.pyi | 11 + .../finheritance_f90/finheritance_f90.pyi | 2 + .../fmodule_derived_alias_f90.pyi | 2 + .../derived_types_direct_bind_c_f90.pyi | 2 + .../derived_types_mixed_bind_c_f90.pyi | 2 + .../contracts/fenums_f90/fenums_f90.pyi | 2 + .../enumerations_direct_bind_c_f90.pyi | 2 + .../enumerations_mixed_bind_c_f90.pyi | 2 + .../fopenmp_runtime_f90.pyi | 2 + .../fruntime_recursion_f90.pyi | 2 + .../contracts/basic_subroutine/m1.pyi | 2 + .../fixtures/contracts/blas_like/__init__.pyi | 2 + .../contracts/external_bundle/__init__.pyi | 2 + .../contracts/fixed_external/__init__.pyi | 2 + .../contracts/free_external/__init__.pyi | 2 + .../standalone_direct_bind_c_f90/__init__.pyi | 2 + .../standalone_mixed_bind_c_f90/__init__.pyi | 2 + .../foperators_f90/foperators_f90.pyi | 2 + .../foverloads_f90/foverloads_f90.pyi | 2 + .../foverloads_fixed/foverloads_fixed.pyi | 2 + .../generic_interfaces_direct_bind_c_f90.pyi | 2 + .../generic_interfaces_mixed_bind_c_f90.pyi | 2 + .../combined_modules/box_ops.pyi | 2 + .../combined_modules/first_math.pyi | 2 + .../combined_modules/second_math.pyi | 2 + .../combined_modules/shared_types.pyi | 2 + .../runtime_abi/fruntime_abi_f90.pyi | 2 + .../fdefault_output/__init__.pyi | 2 + .../fruntime_abi_f90/fruntime_abi_f90.pyi | 2 + .../source_builds/verbose_api/verbose_api.pyi | 2 + .../cli/pipeline/test_stage_dispatch.py | 4 +- .../end_to_end/test_fortran_magic_runtime.py | 2 + .../general/expected/basic_subroutine.json | 349 +- .../expected/compile_time_all_exprs.json | 2567 +++++----- .../expected/compile_time_shape_exprs.json | 653 +-- .../general/expected/derived_type.json | 551 +-- .../expected/derived_types_and_methods.json | 663 +-- .../general/expected/modern_pyi_example.json | 4227 +++++++++-------- .../general/expected/module_vars_use.json | 345 +- .../expected/procedures_and_functions.json | 639 +-- .../scope_name_reuse_combinations.json | 2781 +++++------ .../contracts/fnaming_f90/fnaming_f90.pyi | 2 + .../end_to_end/test_package_exports.py | 3 +- .../contract_import_graph/generated/deep.pyi | 2 + .../contract_import_graph/generated/m1.pyi | 2 + .../generated/__init__.pyi | 2 + .../generated/contract_math_mod.pyi | 2 + .../contract_same_name/generated/__init__.pyi | 2 + .../generated/contract_same_name.pyi | 2 + .../generated/__init__.pyi | 2 + .../pipeline/fixtures/modern_math_physics.pyi | 12 + .../test_contract_package_generation.py | 3 +- .../test_pyi_printer_imports_and_packages.py | 73 +- .../fcommon_block_f90/fcommon_block_f90.pyi | 2 + .../fmodule_vars_f90/fmodule_vars_f90.pyi | 13 + .../contracts/module_exports/__init__.pyi | 2 + .../contracts/module_exports/module1.pyi | 2 + .../contracts/module_exports/module2.pyi | 2 + .../modules_direct_bind_c_f90.pyi | 2 + .../modules_mixed_bind_c_f90.pyi | 2 + .../test_module_variables_and_state.py | 60 + .../contracts/foptional_f90/foptional_f90.pyi | 2 + .../contracts/foptional_fixed/__init__.pyi | 2 + .../optional_arguments_direct_bind_c_f90.pyi | 2 + .../optional_arguments_mixed_bind_c_f90.pyi | 2 + .../contracts/fpointers_f90/fpointers_f90.pyi | 2 + .../fcharacter_edges_f90.pyi | 2 + .../fstring_descriptors_f90.pyi | 31 + .../fixtures/contracts/fstrings/__init__.pyi | 12 + .../contracts/fstrings_f90/fstrings_f90.pyi | 19 + .../strings_direct_bind_c_f90.pyi | 2 + .../strings_mixed_bind_c_f90.pyi | 2 + .../subroutines_direct_bind_c_f90.pyi | 2 + .../subroutines_mixed_bind_c_f90.pyi | 2 + 121 files changed, 7681 insertions(+), 6456 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66a22afa3..877414939 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ release tags add a leading `v` to the package version. ## Unreleased +- A contract states everything it publishes in a closing `__all__`. An import + cannot say whether a name is needed to express a declaration or meant to be + published, because a rename reads the same either way, so the list settles it. + PRIK writes what the source publishes -- the module's own public declarations + and any imported name a `public` statement names -- and the list is there to + be edited: remove a name to stop publishing it, add an imported one to publish + it, or remove the list to publish everything the contract reaches. + +- A re-exported procedure binds the callable its declaring module exported + rather than being wrapped again, so a contract build gives the same object a + source build does, under a renamed re-export as well. + - A published name is followed to the module declaring it, however many modules published it along the way. Reading only the module a `use` names left a name published twice over looking like nothing at all, and the diff --git a/docs/developer/packages/printers.md b/docs/developer/packages/printers.md index f6f7c850d..a7fcc443c 100644 --- a/docs/developer/packages/printers.md +++ b/docs/developer/packages/printers.md @@ -152,12 +152,15 @@ from prik.contracts import Float64, bind def double_value( value: Float64 ) -> Float64: ... + +__all__ = ["double_value"] ``` The native examples prove that punctuation and layout are added to already formed nodes. The `.pyi` import and `@bind` line show that required contract imports and native identity are derived from semantic IR without attaching -wrapper policy. +wrapper policy, and the closing `__all__` states the surface the module +publishes. ## Tests And Evidence diff --git a/docs/user/reference/pyi-format.md b/docs/user/reference/pyi-format.md index 3eb62da4b..efa1669ec 100644 --- a/docs/user/reference/pyi-format.md +++ b/docs/user/reference/pyi-format.md @@ -180,6 +180,39 @@ Aliases change the Python API only. They do not rename native modules, types, or symbols. Conflicting wildcard exports are rejected; resolve them with explicit imports and aliases. +### Stating What A Contract Publishes + +A contract may end with `__all__`, naming every entity it publishes: + +```python +from prik.contracts import Int32 +from .shapes_mod import box + +def area(item: box) -> Int32: ... + +__all__ = ["area"] +``` + +The list is the whole public surface, not only the names a contract re-exports. +It settles a question import syntax cannot answer, because one import serves two +purposes: naming a type a declaration needs, and publishing an entity this +contract means to expose. `from .shapes_mod import box as crate` reads the same +whether `crate` avoids a collision or is published under a new name. + +PRIK writes the list into every generated contract, holding what the Fortran +source publishes: the module's own public declarations, and any imported name it +names in a `public` statement. Edit it freely. + +| Edit | Effect | +| --- | --- | +| Remove a name | The entity stays declared and callable from other contracts, but no longer reaches Python here. | +| Add an imported name | Publishes it here as well, including one imported only to express a declaration. | +| `__all__ = []` | Publishes nothing from this contract. | +| Remove `__all__` | Publishes everything the contract reaches, its declarations and its imports alike. | + +A name in `__all__` must be one the contract declares or imports; naming +anything else is rejected before wrapper planning. + ### Contract Import Graph PRIK parses contract files without executing them. Relative imports recursively diff --git a/prik/cli.py b/prik/cli.py index 40e57d06b..9ed086c96 100644 --- a/prik/cli.py +++ b/prik/cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import ast import json import os import shlex @@ -744,29 +745,58 @@ def _fortran_contract_payload(path: Path, modules, available_modules) -> dict[st def _source_root_stub(module_names: list[str], external_text: list[str]) -> str: + from prik.printers.pyi import PyiPrinter + contract_imports: set[str] = set() + exported_names: list[str] = [] external_sections = [] for text in external_text: - imports, body = _split_contract_imports(text) + imports, exported, body = _split_contract_imports(text) contract_imports.update(imports) + exported_names.extend(name for name in exported if name not in exported_names) if body: external_sections.append(body) contract_section = f"from prik.contracts import {', '.join(sorted(contract_imports))}" if contract_imports else "" lines = [f"from . import {name}" for name in module_names] import_section = "\n".join(line for line in [contract_section, *lines] if line) sections = [import_section, *external_sections] + if exported_names: + # Each source file states what it publishes, and this entry holds them + # all, so one list closes the file the way one does in any contract, + # wrapped the same way a long list is wrapped anywhere else. + sections.append(PyiPrinter.emit_exported_names(exported_names)) return "\n\n".join(section for section in sections if section).strip() -def _split_contract_imports(text: str) -> tuple[set[str], str]: +def _split_contract_imports(text: str) -> tuple[set[str], list[str], str]: + """Separate a contract's required imports and stated exports from its body.""" imports: set[str] = set() + exported: list[str] = [] body_lines = [] + pending: list[str] = [] for line in text.splitlines(): + if pending: + # A long list is written over several lines, so it is read back the + # same way: gather until the brackets close. + pending.append(line) + joined = "\n".join(pending) + if joined.count("[") == joined.count("]"): + exported.extend(ast.literal_eval(joined.split("=", 1)[1].strip())) + pending = [] + continue if line.startswith("from prik.contracts import "): imports.update(item.strip() for item in line.removeprefix("from prik.contracts import ").split(",")) continue + if line.startswith("__all__"): + if line.count("[") == line.count("]"): + exported.extend(ast.literal_eval(line.split("=", 1)[1].strip())) + else: + pending = [line] + continue body_lines.append(line) - return imports, "\n".join(body_lines).strip() + if pending: + raise ValueError(f"Unterminated __all__ in generated contract: {pending[0]!r}") + return imports, exported, "\n".join(body_lines).strip() def _format_pyi_report(semantic_report: dict[str, dict]) -> str: diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index 4fa6b4f32..cd8268533 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -2047,13 +2047,21 @@ class _PyiExportNode: declarations: list[object] = field(default_factory=list) children: dict[str, _PyiExportNode] = field(default_factory=dict) origins: set[Path] = field(default_factory=set) + namespaces: set[str] = field(default_factory=set) + """Child names attached as sub-namespaces rather than bound entities. + + ``from . import other`` builds the package tree, which is structure rather + than a name the contract publishes, so a stated ``__all__`` names entities + and leaves the tree alone. + """ + unpublished: set[str] = field(default_factory=set) - """Names this node resolves for its own declarations but does not export. + """Names this node resolves but its contract left out of ``__all__``. - An import states what a contract needs to express its declarations, which - is not the same as a name it means to publish. Both reach the tree, because - a contract reading from this one still has to resolve what it names, and - only a published name becomes a Python attribute here. + A contract stating no list publishes everything it reaches, so a name is + withheld only where the contract named its surface and left this one off. + Such a name still resolves, because a contract reading from this one has to + resolve what it names; it simply does not become a Python attribute here. """ @@ -2070,14 +2078,17 @@ def _apply_pyi_python_exports(entry: Path, modules_by_path: dict[Path, SemanticM for declaration in _module_declarations(module): _set_declaration_exports(declaration, []) - # The entry contract is the package's own surface: it declares little and - # exists to choose what the package exports, so the names it imports are - # the ones it means to publish. A module contract imports what it needs to - # express its declarations, which is not the same intent. - tree = _pyi_export_tree(entry, modules_by_path, cache={}, pending=set(), publishes_imports=True) + tree = _pyi_export_tree(entry, modules_by_path, cache={}, pending=set()) _record_pyi_exports(tree) + # A declaration published from more than one namespace is one entity, so + # the namespaces after the first bind what the first already exports rather + # than each wrapping the native declaration again. A rename changes the + # name a namespace binds, never the object behind it. for module in modules_by_path.values(): - for declaration in module.classes: + for declaration, entity_kind in ( + *((item, "derived_type") for item in module.classes), + *((item, "procedure") for item in module.functions), + ): exports = _declaration_exports(declaration) if len(exports) < 2: continue @@ -2090,7 +2101,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", + entity_kind=entity_kind, ) ) exports[:] = [primary] @@ -2100,7 +2111,6 @@ def _pyi_export_tree( path: Path, modules_by_path: dict[Path, SemanticModule], *, - publishes_imports: bool = False, cache: dict[Path, _PyiExportNode], pending: set[Path], ) -> _PyiExportNode: @@ -2137,15 +2147,15 @@ def _pyi_export_tree( for semantic_import in module.imports: if not isinstance(semantic_import, SemanticImport) or not semantic_import.module.startswith("."): continue - _merge_relative_import( - tree, - path, - semantic_import, - modules_by_path, - cache, - pending, - publishes_imports=publishes_imports, - ) + _merge_relative_import(tree, path, semantic_import, modules_by_path, cache, pending) + if module.exported_names is not None: + # A stated list is the whole public surface: a name on it is published + # whether this contract declares or imports it, and one left off stays + # available to express declarations without reaching Python. A contract + # stating no list has named no surface, so everything it reaches is + # published, which is what an entry contract selecting from its package + # has always meant. + _apply_stated_exports(tree, path, module.exported_names) pending.remove(path) cache[path] = tree return tree @@ -2158,7 +2168,6 @@ def _merge_relative_import( modules_by_path: dict[Path, SemanticModule], cache: dict[Path, _PyiExportNode], pending: set[Path], - publishes_imports: bool = False, ) -> None: """Merge one relative import's exports into the current namespace tree. @@ -2178,19 +2187,15 @@ def _merge_relative_import( continue if item.source not in dependency_tree.children: raise ValueError(f"Imported semantic name {item.source!r} not found in {dependency}") - local = item.target or item.source - _merge_export_child(tree, local, dependency_tree.children[item.source], origin=path) - if item.target is None and not publishes_imports: - # A plain import names what this contract needs to express its - # own declarations. Re-export is stated by aliasing the name - # explicitly, as a stub does for anything it means to publish. - tree.unpublished.add(local) + _merge_export_child(tree, item.target or item.source, dependency_tree.children[item.source], origin=path) return for item in semantic_import.items: dependency = _relative_import_path(path, semantic_import.module, item.source) dependency_tree = _required_export_tree(dependency, modules_by_path, cache, pending) - _merge_export_child(tree, item.target or item.source, dependency_tree, origin=path) + local = item.target or item.source + _merge_export_child(tree, local, dependency_tree, origin=path) + tree.namespaces.add(local) def _relative_import_path(path: Path, module: str, imported_module: str) -> Path: @@ -2214,6 +2219,16 @@ def _required_export_tree( return _pyi_export_tree(path, modules_by_path, cache=cache, pending=pending) +def _apply_stated_exports(tree: _PyiExportNode, path: Path, exported_names: list[str]) -> None: + """Publish exactly the names one contract states, and nothing else.""" + stated = list(dict.fromkeys(exported_names)) + missing = [name for name in stated if name not in tree.children] + if missing: + raise ValueError(f"{path}: __all__ names nothing this contract declares or imports: {missing}") + published = set(stated) | tree.namespaces + tree.unpublished = {name for name in tree.children if name not in published} + + def _merge_export_child(tree: _PyiExportNode, name: str, child: _PyiExportNode, *, origin: Path) -> None: """Insert one named export into ``tree`` or reject a conflicting origin. diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index 7321a4568..63243b355 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -580,8 +580,68 @@ def _visit_SemanticModule( sections: list[str] = [] self._append_imports(sections, module, context) sections.extend(body_sections) + # The list reads as a summary of what came before it, so it closes the + # contract rather than standing between the imports and the + # declarations it names. + exported = self._module_exported_names(module, context, overload_targets) + # A contract with nothing in it states nothing; the list summarises a + # surface, and an empty file has none to summarise. + if exported is not None and sections: + sections.append(self.emit_exported_names(exported)) return "\n".join(sections).rstrip() + @staticmethod + def emit_exported_names(exported: list[str]) -> str: + """Render the list of names a contract states that it publishes.""" + if not exported: + return "__all__ = []" + items = ", ".join(json.dumps(name) for name in exported) + line = f"__all__ = [{items}]" + if len(line) <= 116: + return line + body = "\n".join(f" {json.dumps(name)}," for name in exported) + return f"__all__ = [\n{body}\n]" + + def _module_exported_names( + self, + module: SemanticModule, + context: _PyiEmissionContext, + overload_targets: set[str], + ) -> list[str] | None: + """Return every name this contract publishes, in the order it writes them. + + The list states the module's whole public surface rather than only the + names it re-exports, so removing one stops publishing it and adding one + publishes something the contract names for its declarations alone. A + contract that was read rather than derived keeps the list it stated. + """ + if module.exported_names is not None: + return list(module.exported_names) + if module.metadata.get(PYI_LOADED_METADATA): + return None + names: list[str] = [] + for semantic_class in self._contract_items(module.classes): + if not self._is_private(semantic_class): + names.append(semantic_class.name) + names.extend(str(prototype.name) for prototype in module.prototypes) + for variable in self._contract_items(module.variables): + if getattr(variable, "visibility", "public") != "private": + names.append(self._module_variable_name(variable, context)) + for function in self._contract_items(module.functions, keep_names=overload_targets): + if not self._is_private(function): + names.append(self._callable_name(function, context)) + names.extend(str(overload_set.name) for overload_set in module.overload_sets) + for reexport in module.reexports: + if self._is_source_kind_import(str(reexport.origin_module)): + continue + # A prototype keeps its declared spelling wherever it is written, so + # the name published for it is the one its import binds. + local = str(reexport.local_name) + names.append( + local if reexport.entity_kind == "prototype" else self._public_import_name(local, public_names=True) + ) + return list(dict.fromkeys(names)) + # ------------------------------------------------------------------ # Shared helpers # ------------------------------------------------------------------ @@ -1525,7 +1585,6 @@ def _append_imports( public_names=context.normalize_fortran_public_names, verbatim_names=verbatim, published_names_by_module=self._published_names_by_module, - reexported=frozenset(str(item.local_name).casefold() for item in module.reexports), ) ) if contract_import or imports: @@ -1545,9 +1604,45 @@ def _effective_imports(cls, module: SemanticModule) -> list[str | SemanticImport 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_reexport_imports(module, imports)) imports.extend(cls._missing_procedure_namespace_imports(procedure_namespaces, satisfied_namespaces)) return imports + @classmethod + def _missing_reexport_imports( + cls, + module: SemanticModule, + imports: list[str | SemanticImport], + ) -> list[SemanticImport]: + """Return imports binding published names no import already names. + + A plain ``use`` carries every public name of the module it reads, so a + name published through one is not written in any import list. The + contract has to name it explicitly, because a published name must be + one the contract itself reaches. + """ + bound = { + (item.target or item.source).casefold() + for imported in imports + if isinstance(imported, SemanticImport) + for item in imported.items + } + required: dict[str, list[SemanticImportItem]] = {} + for reexport in module.reexports: + local = str(reexport.local_name) + # An intrinsic module has no contract to read a name from, so a + # name published out of one states nothing this contract can bind. + if cls._is_source_kind_import(str(reexport.origin_module)): + continue + if local.casefold() in bound or not reexport.origin_module: + continue + source = str(reexport.source_name) or local + required.setdefault(str(reexport.origin_module), []).append( + SemanticImportItem(source=source, target=local if local != source else None) + ) + bound.add(local.casefold()) + return [SemanticImport(module=name, items=items) for name, items in required.items()] + @classmethod def _missing_prototype_imports( cls, @@ -1825,7 +1920,6 @@ def _emit_import( public_names: bool = False, verbatim_names: dict[tuple[str, str], str] | None = None, published_names_by_module: dict[str, dict[str, str]] | None = None, - reexported: frozenset[str] = frozenset(), ) -> str: """Emit import syntax.""" if isinstance(imp, str): @@ -1841,7 +1935,6 @@ def _emit_import( verbatim_names=verbatim_names, source_module=source_module, published_names=published_names, - reexported=reexported, ) for item in imp.items ) @@ -1856,7 +1949,6 @@ def _emit_import_item( verbatim_names: dict[tuple[str, str], str] | None = None, source_module: str = "", published_names: dict[str, str] | None = None, - reexported: frozenset[str] = frozenset(), ) -> str: """Emit import item syntax. @@ -1880,17 +1972,16 @@ def _emit_import_item( # A prototype keeps its declared spelling on both sides, because an # annotation naming it is written exactly that way. return prototype if local == prototype else f"{prototype} as {local}" + # The name read from the other contract is the one it published; the + # name bound here is what this contract calls the entity. They part + # company when a rename says so, and also when a collision moved the + # published name aside. source = PyiPrinter._public_import_name(item.source, public_names=public_names) if public_names and published_names: source = published_names.get(item.source.casefold(), source) - target = PyiPrinter._public_import_name(item.target, public_names=public_names) - if target and target != source: - return f"{source} as {target}" - if local.casefold() in reexported: - # Publishing an imported name is stated by aliasing it explicitly, - # so a contract reading this one can tell a re-export from an import - # written only to express a declaration. - return f"{source} as {source}" + bound = PyiPrinter._public_import_name(local, public_names=public_names) + if bound and bound != source: + return f"{source} as {bound}" return source @staticmethod diff --git a/prik/semantics/models.py b/prik/semantics/models.py index 12e73f87c..0dfa1227c 100644 --- a/prik/semantics/models.py +++ b/prik/semantics/models.py @@ -715,6 +715,15 @@ class SemanticModule: imports: list[str | SemanticImport] = field(default_factory=list) + exported_names: list[str] | None = None + """Every name this module publishes, or ``None`` when it states no list. + + A contract states its whole public surface here, so a name it imports is + published when it is listed and stays a dependency when it is not. The list + is written to be edited: a generated contract fills it with what the source + publishes, and removing or adding a name changes what reaches Python. + """ + metadata: dict[str, Any] = field(default_factory=dict) origin: SemanticOrigin = field(default_factory=SemanticOrigin, compare=False) diff --git a/prik/semantics/pyi2ir.py b/prik/semantics/pyi2ir.py index e98a889c1..5af739324 100644 --- a/prik/semantics/pyi2ir.py +++ b/prik/semantics/pyi2ir.py @@ -3768,6 +3768,21 @@ def _visit_AnnAssign(self, node: ast.AnnAssign) -> None: """Convert a module variable declaration.""" self.parser.module.variables.append(self.parser.ann_assign(node)) + def _visit_Assign(self, node: ast.Assign) -> None: + """Record the list of names this contract states that it publishes.""" + targets = [target for target in node.targets if isinstance(target, ast.Name)] + if len(targets) != 1 or targets[0].id != "__all__": + raise ValueError(f"Unsupported .pyi node: {_node_text(node)!r}") + if self.parser.module.exported_names is not None: + raise ValueError("A contract states __all__ once") + try: + names = ast.literal_eval(node.value) + except ValueError as exc: + raise ValueError(f"__all__ expects a list of name strings: {_node_text(node)!r}") from exc + if not isinstance(names, list | tuple) or not all(isinstance(name, str) for name in names): + raise ValueError(f"__all__ expects a list of name strings: {_node_text(node)!r}") + self.parser.module.exported_names = [str(name) for name in names] + def _visit_ClassDef(self, node: ast.ClassDef) -> None: """Convert a semantic class declaration.""" decorators = self.parser.decorators(node.decorator_list, context="class") diff --git a/tests/c/fixtures/pyi/general/basic_array_update.pyi b/tests/c/fixtures/pyi/general/basic_array_update.pyi index 2a6af576a..703712604 100644 --- a/tests/c/fixtures/pyi/general/basic_array_update.pyi +++ b/tests/c/fixtures/pyi/general/basic_array_update.pyi @@ -10,3 +10,5 @@ def add1_strided( x: Float64[...], incx: Int ) -> None: ... + +__all__ = ["add1", "add1_strided"] diff --git a/tests/c/fixtures/pyi/general/c_richer_features.pyi b/tests/c/fixtures/pyi/general/c_richer_features.pyi index 981880eaf..1967acf07 100644 --- a/tests/c/fixtures/pyi/general/c_richer_features.pyi +++ b/tests/c/fixtures/pyi/general/c_richer_features.pyi @@ -43,3 +43,17 @@ def prik_fill_matrix( cols: SizeT, matrix: Float64[rows, cols] ) -> None: ... + +__all__ = [ + "prik_flags", + "prik_context", + "prik_scalar", + "PRIK_STATUS_OK", + "PRIK_STATUS_RETRY", + "PRIK_STATUS_ERROR", + "prik_slow_path", + "prik_sort", + "prik_register_callback", + "prik_status_message", + "prik_fill_matrix", +] diff --git a/tests/c/fixtures/pyi/general/constants.pyi b/tests/c/fixtures/pyi/general/constants.pyi index a43e12456..5e3448dd3 100644 --- a/tests/c/fixtures/pyi/general/constants.pyi +++ b/tests/c/fixtures/pyi/general/constants.pyi @@ -19,3 +19,15 @@ def coordinate_axis_name( ) -> Addr(Int8): ... def coordinate_axis_count() -> SizeT: ... + +__all__ = [ + "COORD_X", + "COORD_Y", + "COORD_Z", + "PRIK_GENERAL_NMAX", + "PRIK_GENERAL_ORIGIN_RANK", + "nmax", + "origin", + "coordinate_axis_name", + "coordinate_axis_count", +] diff --git a/tests/c/fixtures/pyi/general/math_api.pyi b/tests/c/fixtures/pyi/general/math_api.pyi index eefc4bd71..2814f82a4 100644 --- a/tests/c/fixtures/pyi/general/math_api.pyi +++ b/tests/c/fixtures/pyi/general/math_api.pyi @@ -20,3 +20,5 @@ def dot( def fill_identity3( a: Float64[3, 3] ) -> None: ... + +__all__ = ["norm2", "scale", "dot", "fill_identity3"] diff --git a/tests/c/fixtures/pyi/general/mesh.pyi b/tests/c/fixtures/pyi/general/mesh.pyi index a4d080182..11cc0679d 100644 --- a/tests/c/fixtures/pyi/general/mesh.pyi +++ b/tests/c/fixtures/pyi/general/mesh.pyi @@ -26,3 +26,5 @@ def mesh_node_at( mesh: mesh, index: SizeT ) -> node: ... + +__all__ = ["node", "mesh", "node_move", "mesh_init", "mesh_clear", "mesh_node_at"] diff --git a/tests/c/fixtures/pyi/general/modern_math_physics.pyi b/tests/c/fixtures/pyi/general/modern_math_physics.pyi index 257a24a9a..e092badc4 100644 --- a/tests/c/fixtures/pyi/general/modern_math_physics.pyi +++ b/tests/c/fixtures/pyi/general/modern_math_physics.pyi @@ -46,3 +46,15 @@ def fill_identity3_modern( def normalize_particle( p: modern_particle ) -> None: ... + +__all__ = [ + "modern_particle", + "vector3", + "modern_counter", + "init_particle", + "kinetic_energy", + "scale_vector", + "dot3", + "fill_identity3_modern", + "normalize_particle", +] diff --git a/tests/c/fixtures/pyi/general/name_reuse.pyi b/tests/c/fixtures/pyi/general/name_reuse.pyi index 0188ececc..630b0760e 100644 --- a/tests/c/fixtures/pyi/general/name_reuse.pyi +++ b/tests/c/fixtures/pyi/general/name_reuse.pyi @@ -38,3 +38,18 @@ def convert_to_string( def convert_to_logical( same_name: Int8[...] ) -> Bool: ... + +__all__ = [ + "same_name", + "same_name_i", + "same_name_r", + "same_name_l", + "same_name_c", + "same_name_s", + "do_work_i", + "do_work_r", + "do_work_l", + "convert_to_complex", + "convert_to_string", + "convert_to_logical", +] diff --git a/tests/c/fixtures/pyi/general/particles.pyi b/tests/c/fixtures/pyi/general/particles.pyi index 04db96547..cd45fd408 100644 --- a/tests/c/fixtures/pyi/general/particles.pyi +++ b/tests/c/fixtures/pyi/general/particles.pyi @@ -20,3 +20,5 @@ def particle_move( ) -> None: ... def particle_current() -> particle: ... + +__all__ = ["particle", "particle_touch", "particle_reset", "particle_move", "particle_current"] diff --git a/tests/c/fixtures/pyi/general/shape_exprs.pyi b/tests/c/fixtures/pyi/general/shape_exprs.pyi index 6b17c607b..3528a042e 100644 --- a/tests/c/fixtures/pyi/general/shape_exprs.pyi +++ b/tests/c/fixtures/pyi/general/shape_exprs.pyi @@ -35,3 +35,15 @@ def all_exprs( x8: Int[(8 + 3) * (2 + 1) - 1], x9: Int[(8 - 3) * (8 - 2)] ) -> None: ... + +__all__ = [ + "PRIK_EXPR_N0", + "PRIK_EXPR_N1", + "PRIK_EXPR_A", + "PRIK_EXPR_B", + "PRIK_EXPR_C", + "fill_grid", + "update_plane", + "use_expr", + "all_exprs", +] diff --git a/tests/c/infrastructure/jupyter/end_to_end/test_c_magic_runtime.py b/tests/c/infrastructure/jupyter/end_to_end/test_c_magic_runtime.py index 29c50a2f8..933f82fa2 100644 --- a/tests/c/infrastructure/jupyter/end_to_end/test_c_magic_runtime.py +++ b/tests/c/infrastructure/jupyter/end_to_end/test_c_magic_runtime.py @@ -76,6 +76,8 @@ def counting_build(*args, **kwargs): "from prik.contracts import Float64, bind", ) contract = contract.replace("def square(", '@bind("square")\ndef squared(') + # Renaming a declaration renames what the contract publishes. + contract = contract.replace('__all__ = ["square"]', '__all__ = ["squared"]') line = magic_line.removeprefix("%%pyi").strip() magic.pyi(line, contract) diff --git a/tests/c/records/semantics/test_c_record_semantics.py b/tests/c/records/semantics/test_c_record_semantics.py index 81e8ee38a..cf9bd30a1 100644 --- a/tests/c/records/semantics/test_c_record_semantics.py +++ b/tests/c/records/semantics/test_c_record_semantics.py @@ -117,6 +117,7 @@ def test_c2ir_private_include_types_remain_available_as_opaque_handles(): assert ( stubs["private"] == "from prik.contracts import CStruct, Opaque\n\nclass private_context(CStruct, Opaque):\n pass" + '\n\n__all__ = ["private_context"]' ) diff --git a/tests/fortran/allocatables/end_to_end/fixtures/contracts/fallocatable_views_f90/fallocatable_views_f90.pyi b/tests/fortran/allocatables/end_to_end/fixtures/contracts/fallocatable_views_f90/fallocatable_views_f90.pyi index b9dcdbcb8..027a458dd 100644 --- a/tests/fortran/allocatables/end_to_end/fixtures/contracts/fallocatable_views_f90/fallocatable_views_f90.pyi +++ b/tests/fortran/allocatables/end_to_end/fixtures/contracts/fallocatable_views_f90/fallocatable_views_f90.pyi @@ -82,3 +82,21 @@ def make_matrix( n: Int32, m: Int32 ) -> Allocatable[Float64[:, :]]: ... + +__all__ = [ + "buffer", + "module_values", + "allocate_module_values", + "deallocate_module_values", + "scale_module_values", + "module_values_sum", + "build_values", + "build_matrix", + "make_values", + "replace_values", + "zero_alloc_vector", + "maybe_alloc_vector", + "zero_alloc_matrix", + "maybe_alloc_matrix", + "make_matrix", +] diff --git a/tests/fortran/allocatables/end_to_end/fixtures/contracts/fscalar_allocatables_f90/fscalar_allocatables_f90.pyi b/tests/fortran/allocatables/end_to_end/fixtures/contracts/fscalar_allocatables_f90/fscalar_allocatables_f90.pyi index 71f50924e..e51453ff4 100644 --- a/tests/fortran/allocatables/end_to_end/fixtures/contracts/fscalar_allocatables_f90/fscalar_allocatables_f90.pyi +++ b/tests/fortran/allocatables/end_to_end/fixtures/contracts/fscalar_allocatables_f90/fscalar_allocatables_f90.pyi @@ -33,3 +33,15 @@ def create_allocatable() -> Float64 | None: ... def maybe_allocatable( flag: Int32 ) -> Float64 | None: ... + +__all__ = [ + "optional_scale", + "clear_module_value", + "set_module_value", + "bump_module_value", + "echo_allocatable", + "update_allocatable", + "clear_allocatable_value", + "create_allocatable", + "maybe_allocatable", +] diff --git a/tests/fortran/allocatables/end_to_end/fixtures/routing/contracts/allocatables_direct_bind_c_f90/allocatables_direct_bind_c_f90.pyi b/tests/fortran/allocatables/end_to_end/fixtures/routing/contracts/allocatables_direct_bind_c_f90/allocatables_direct_bind_c_f90.pyi index aab1df615..022fda2e4 100644 --- a/tests/fortran/allocatables/end_to_end/fixtures/routing/contracts/allocatables_direct_bind_c_f90/allocatables_direct_bind_c_f90.pyi +++ b/tests/fortran/allocatables/end_to_end/fixtures/routing/contracts/allocatables_direct_bind_c_f90/allocatables_direct_bind_c_f90.pyi @@ -14,3 +14,5 @@ def direct_allocate( def direct_pointer_sum( values: Annotated[Pointer[Float64[:]], PointerAssociation("runtime"), Ownership("caller"), Transfer("call_local"), Destruction("none")] ) -> Float64: ... + +__all__ = ["direct_optional_state", "direct_allocate", "direct_pointer_sum"] diff --git a/tests/fortran/allocatables/end_to_end/fixtures/routing/contracts/allocatables_mixed_bind_c_f90/allocatables_mixed_bind_c_f90.pyi b/tests/fortran/allocatables/end_to_end/fixtures/routing/contracts/allocatables_mixed_bind_c_f90/allocatables_mixed_bind_c_f90.pyi index 115e5edcb..3b2104f7f 100644 --- a/tests/fortran/allocatables/end_to_end/fixtures/routing/contracts/allocatables_mixed_bind_c_f90/allocatables_mixed_bind_c_f90.pyi +++ b/tests/fortran/allocatables/end_to_end/fixtures/routing/contracts/allocatables_mixed_bind_c_f90/allocatables_mixed_bind_c_f90.pyi @@ -8,3 +8,5 @@ def direct_allocate( def adapted_sum( values: Allocatable[Float64[:]] ) -> Float64: ... + +__all__ = ["direct_allocate", "adapted_sum"] diff --git a/tests/fortran/arrays/end_to_end/fixtures/contracts/array_ops/array_ops.pyi b/tests/fortran/arrays/end_to_end/fixtures/contracts/array_ops/array_ops.pyi index 65c988477..9c248722c 100644 --- a/tests/fortran/arrays/end_to_end/fixtures/contracts/array_ops/array_ops.pyi +++ b/tests/fortran/arrays/end_to_end/fixtures/contracts/array_ops/array_ops.pyi @@ -58,3 +58,16 @@ def fill_optional( def automatic_vector( count: Int32 ) -> Float64[count]: ... + +__all__ = [ + "scale_matrix", + "shift", + "sum_columns", + "sum_flat", + "sum_flat_columns", + "scale_visible_rows", + "scale_without_intent", + "mutate_optional", + "fill_optional", + "automatic_vector", +] diff --git a/tests/fortran/arrays/end_to_end/fixtures/contracts/farray_contracts_f90/farray_contracts_f90.pyi b/tests/fortran/arrays/end_to_end/fixtures/contracts/farray_contracts_f90/farray_contracts_f90.pyi index dcc866593..281a7d421 100644 --- a/tests/fortran/arrays/end_to_end/fixtures/contracts/farray_contracts_f90/farray_contracts_f90.pyi +++ b/tests/fortran/arrays/end_to_end/fixtures/contracts/farray_contracts_f90/farray_contracts_f90.pyi @@ -98,3 +98,26 @@ def shift15( values: Float64[::, ::, ::, ::, ::, ::, ::, ::, ::, ::, ::, ::, ::, ::, ::], out: Float64[::, ::, ::, ::, ::, ::, ::, ::, ::, ::, ::, ::, ::, ::, ::] ) -> None: ... + +__all__ = [ + "sum_assumed_size", + "scale_lower", + "sum_in", + "bump_inout", + "fill_out", + "shift1", + "shift2", + "shift3", + "shift4", + "shift5", + "shift6", + "shift7", + "shift8", + "shift9", + "shift10", + "shift11", + "shift12", + "shift13", + "shift14", + "shift15", +] diff --git a/tests/fortran/arrays/end_to_end/fixtures/contracts/farray_results_f90/farray_results_f90.pyi b/tests/fortran/arrays/end_to_end/fixtures/contracts/farray_results_f90/farray_results_f90.pyi index dac619931..9763d894d 100644 --- a/tests/fortran/arrays/end_to_end/fixtures/contracts/farray_results_f90/farray_results_f90.pyi +++ b/tests/fortran/arrays/end_to_end/fixtures/contracts/farray_results_f90/farray_results_f90.pyi @@ -73,3 +73,31 @@ def maybe_alloc_matrix( rows: Int32, cols: Int32 ) -> Allocatable[Float64[:, :]]: ... + +__all__ = [ + "fixed_vector", + "automatic_vector", + "size_intrinsic_vector", + "automatic_matrix", + "rank3_cube", + "rank1_result", + "rank2_result", + "rank3_result", + "rank4_result", + "rank5_result", + "rank6_result", + "rank7_result", + "rank8_result", + "rank9_result", + "rank10_result", + "rank11_result", + "rank12_result", + "rank13_result", + "rank14_result", + "rank15_result", + "zero_vector", + "zero_alloc_vector", + "maybe_alloc_vector", + "zero_alloc_matrix", + "maybe_alloc_matrix", +] diff --git a/tests/fortran/arrays/end_to_end/fixtures/contracts/fassumed_rank_f90/fassumed_rank_f90.pyi b/tests/fortran/arrays/end_to_end/fixtures/contracts/fassumed_rank_f90/fassumed_rank_f90.pyi index cb309f10c..07b1c4045 100644 --- a/tests/fortran/arrays/end_to_end/fixtures/contracts/fassumed_rank_f90/fassumed_rank_f90.pyi +++ b/tests/fortran/arrays/end_to_end/fixtures/contracts/fassumed_rank_f90/fassumed_rank_f90.pyi @@ -12,3 +12,5 @@ def rank_pair_score( left: Float64[...], right: Float64[...] ) -> Int32: ... + +__all__ = ["rank_weighted_sum", "bump_assumed_rank", "rank_pair_score"] 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 876fd52cc..d476a731c 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 @@ -727,3 +727,91 @@ def is_even_i4( X: Int32[N], R: Bool8[N] ) -> Returns["N", Int32]: ... + +__all__ = [ + "square_r4", + "square_r8", + "square_i4", + "square_c4", + "square_c8", + "cube_r4", + "cube_r8", + "cube_i4", + "add_r4", + "add_r8", + "add_i4", + "add_c4", + "add_c8", + "sub_r4", + "sub_r8", + "sub_i4", + "mul_r4", + "mul_r8", + "mul_i4", + "div_r4", + "div_r8", + "pow_r4", + "pow_r8", + "abs_r4", + "abs_r8", + "abs_i4", + "neg_r4", + "neg_r8", + "neg_i4", + "sin_r4", + "sin_r8", + "cos_r4", + "cos_r8", + "tan_r4", + "tan_r8", + "asin_r4", + "asin_r8", + "acos_r4", + "acos_r8", + "atan_r4", + "atan_r8", + "atan2_r4", + "atan2_r8", + "exp_r4", + "exp_r8", + "log_r4", + "log_r8", + "log10_r4", + "log10_r8", + "sqrt_r4", + "sqrt_r8", + "hypot_r4", + "hypot_r8", + "min_r4", + "min_r8", + "min_i4", + "max_r4", + "max_r8", + "max_i4", + "sign_r4", + "sign_r8", + "mod_i4", + "mod_r4", + "mod_r8", + "deg2rad_r4", + "deg2rad_r8", + "rad2deg_r4", + "rad2deg_r8", + "dist2_r4", + "dist2_r8", + "dot2_r4", + "dot2_r8", + "dot3_r4", + "dot3_r8", + "conj_c4", + "conj_c8", + "real_c4", + "real_c8", + "aimag_c4", + "aimag_c8", + "abs_c4", + "abs_c8", + "is_positive_r4", + "is_positive_r8", + "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 74a31ac5e..34b185943 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 @@ -1285,3 +1285,176 @@ def is_even_i4_strided( X: Int32[::], R: Bool8[::] ) -> Returns["N", Int32]: ... + +__all__ = [ + "square_r4_contiguous", + "square_r8_contiguous", + "square_i4_contiguous", + "square_c4_contiguous", + "square_c8_contiguous", + "cube_r4_contiguous", + "cube_r8_contiguous", + "cube_i4_contiguous", + "add_r4_contiguous", + "add_r8_contiguous", + "add_i4_contiguous", + "add_c4_contiguous", + "add_c8_contiguous", + "sub_r4_contiguous", + "sub_r8_contiguous", + "sub_i4_contiguous", + "mul_r4_contiguous", + "mul_r8_contiguous", + "mul_i4_contiguous", + "div_r4_contiguous", + "div_r8_contiguous", + "pow_r4_contiguous", + "pow_r8_contiguous", + "abs_r4_contiguous", + "abs_r8_contiguous", + "abs_i4_contiguous", + "neg_r4_contiguous", + "neg_r8_contiguous", + "neg_i4_contiguous", + "sin_r4_contiguous", + "sin_r8_contiguous", + "cos_r4_contiguous", + "cos_r8_contiguous", + "tan_r4_contiguous", + "tan_r8_contiguous", + "asin_r4_contiguous", + "asin_r8_contiguous", + "acos_r4_contiguous", + "acos_r8_contiguous", + "atan_r4_contiguous", + "atan_r8_contiguous", + "atan2_r4_contiguous", + "atan2_r8_contiguous", + "exp_r4_contiguous", + "exp_r8_contiguous", + "log_r4_contiguous", + "log_r8_contiguous", + "log10_r4_contiguous", + "log10_r8_contiguous", + "sqrt_r4_contiguous", + "sqrt_r8_contiguous", + "hypot_r4_contiguous", + "hypot_r8_contiguous", + "min_r4_contiguous", + "min_r8_contiguous", + "min_i4_contiguous", + "max_r4_contiguous", + "max_r8_contiguous", + "max_i4_contiguous", + "sign_r4_contiguous", + "sign_r8_contiguous", + "mod_i4_contiguous", + "mod_r4_contiguous", + "mod_r8_contiguous", + "deg2rad_r4_contiguous", + "deg2rad_r8_contiguous", + "rad2deg_r4_contiguous", + "rad2deg_r8_contiguous", + "dist2_r4_contiguous", + "dist2_r8_contiguous", + "dot2_r4_contiguous", + "dot2_r8_contiguous", + "dot3_r4_contiguous", + "dot3_r8_contiguous", + "conj_c4_contiguous", + "conj_c8_contiguous", + "real_c4_contiguous", + "real_c8_contiguous", + "aimag_c4_contiguous", + "aimag_c8_contiguous", + "abs_c4_contiguous", + "abs_c8_contiguous", + "is_positive_r4_contiguous", + "is_positive_r8_contiguous", + "is_even_i4_contiguous", + "square_r4_strided", + "square_r8_strided", + "square_i4_strided", + "square_c4_strided", + "square_c8_strided", + "cube_r4_strided", + "cube_r8_strided", + "cube_i4_strided", + "add_r4_strided", + "add_r8_strided", + "add_i4_strided", + "add_c4_strided", + "add_c8_strided", + "sub_r4_strided", + "sub_r8_strided", + "sub_i4_strided", + "mul_r4_strided", + "mul_r8_strided", + "mul_i4_strided", + "div_r4_strided", + "div_r8_strided", + "pow_r4_strided", + "pow_r8_strided", + "abs_r4_strided", + "abs_r8_strided", + "abs_i4_strided", + "neg_r4_strided", + "neg_r8_strided", + "neg_i4_strided", + "sin_r4_strided", + "sin_r8_strided", + "cos_r4_strided", + "cos_r8_strided", + "tan_r4_strided", + "tan_r8_strided", + "asin_r4_strided", + "asin_r8_strided", + "acos_r4_strided", + "acos_r8_strided", + "atan_r4_strided", + "atan_r8_strided", + "atan2_r4_strided", + "atan2_r8_strided", + "exp_r4_strided", + "exp_r8_strided", + "log_r4_strided", + "log_r8_strided", + "log10_r4_strided", + "log10_r8_strided", + "sqrt_r4_strided", + "sqrt_r8_strided", + "hypot_r4_strided", + "hypot_r8_strided", + "min_r4_strided", + "min_r8_strided", + "min_i4_strided", + "max_r4_strided", + "max_r8_strided", + "max_i4_strided", + "sign_r4_strided", + "sign_r8_strided", + "mod_i4_strided", + "mod_r4_strided", + "mod_r8_strided", + "deg2rad_r4_strided", + "deg2rad_r8_strided", + "rad2deg_r4_strided", + "rad2deg_r8_strided", + "dist2_r4_strided", + "dist2_r8_strided", + "dot2_r4_strided", + "dot2_r8_strided", + "dot3_r4_strided", + "dot3_r8_strided", + "conj_c4_strided", + "conj_c8_strided", + "real_c4_strided", + "real_c8_strided", + "aimag_c4_strided", + "aimag_c8_strided", + "abs_c4_strided", + "abs_c8_strided", + "is_positive_r4_strided", + "is_positive_r8_strided", + "is_even_i4_strided", +] diff --git a/tests/fortran/arrays/end_to_end/fixtures/contracts/multid_arrays/multid_arrays.pyi b/tests/fortran/arrays/end_to_end/fixtures/contracts/multid_arrays/multid_arrays.pyi index aba84cc11..4d2a04b5e 100644 --- a/tests/fortran/arrays/end_to_end/fixtures/contracts/multid_arrays/multid_arrays.pyi +++ b/tests/fortran/arrays/end_to_end/fixtures/contracts/multid_arrays/multid_arrays.pyi @@ -37,3 +37,13 @@ def checksum3_strided( a: Float64[::, ::, ::], checksum: Float64[1] ) -> None: ... + +__all__ = [ + "scale2_contiguous", + "scale2_strided", + "checksum2_strided", + "scale2_explicit", + "shift3_contiguous", + "shift3_strided", + "checksum3_strided", +] diff --git a/tests/fortran/arrays/end_to_end/fixtures/routing/contracts/arrays_direct_bind_c_f90/arrays_direct_bind_c_f90.pyi b/tests/fortran/arrays/end_to_end/fixtures/routing/contracts/arrays_direct_bind_c_f90/arrays_direct_bind_c_f90.pyi index bb5fefe55..e7509d2f7 100644 --- a/tests/fortran/arrays/end_to_end/fixtures/routing/contracts/arrays_direct_bind_c_f90/arrays_direct_bind_c_f90.pyi +++ b/tests/fortran/arrays/end_to_end/fixtures/routing/contracts/arrays_direct_bind_c_f90/arrays_direct_bind_c_f90.pyi @@ -30,3 +30,5 @@ def scale_matrix( columns: Int32, values: Float64[rows, columns] ) -> None: ... + +__all__ = ["sum_values", "scale_values", "all_flags", "invert_flags", "scale_matrix"] diff --git a/tests/fortran/arrays/end_to_end/fixtures/routing/contracts/arrays_mixed_bind_c_f90/arrays_mixed_bind_c_f90.pyi b/tests/fortran/arrays/end_to_end/fixtures/routing/contracts/arrays_mixed_bind_c_f90/arrays_mixed_bind_c_f90.pyi index 08121f3e2..23bd86096 100644 --- a/tests/fortran/arrays/end_to_end/fixtures/routing/contracts/arrays_mixed_bind_c_f90/arrays_mixed_bind_c_f90.pyi +++ b/tests/fortran/arrays/end_to_end/fixtures/routing/contracts/arrays_mixed_bind_c_f90/arrays_mixed_bind_c_f90.pyi @@ -11,3 +11,5 @@ def adapted_sum( n: Int32, values: Float64[n] ) -> Float64: ... + +__all__ = ["direct_sum", "adapted_sum"] 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 863dfa6fc..1d2349d17 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 @@ -74,3 +74,17 @@ def apply_point_callback( value: point_t, output: point_t ) -> None: ... + +__all__ = [ + "point_t", + "value_callback", + "scalar_storage_callback", + "array_storage_callback", + "string_storage_callback", + "point_callback", + "apply_value_callback", + "apply_scalar_storage_callback", + "apply_array_storage_callback", + "apply_string_storage_callback", + "apply_point_callback", +] 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 c2751e0af..24f2590cb 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 @@ -38,3 +38,12 @@ def apply_assumed_shape( values: Float64[::], doubled: Float64[::] ) -> None: ... + +__all__ = [ + "reduce_callback", + "transform_callback", + "assumed_shape_callback", + "apply_reduce", + "apply_transform", + "apply_assumed_shape", +] diff --git a/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_scalar_f90/fcallback_scalar_f90.pyi b/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_scalar_f90/fcallback_scalar_f90.pyi index 1e9b2d239..5d088e9e7 100644 --- a/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_scalar_f90/fcallback_scalar_f90.pyi +++ b/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_scalar_f90/fcallback_scalar_f90.pyi @@ -32,3 +32,5 @@ def call_notify( callback: notify_callback, value: Float64 ) -> None: ... + +__all__ = ["scalar_callback", "notify_callback", "callback", "apply_scalar", "apply_explicit", "call_notify"] diff --git a/tests/fortran/callbacks/end_to_end/fixtures/routing/contracts/callbacks_direct_bind_c_f90/callbacks_direct_bind_c_f90.pyi b/tests/fortran/callbacks/end_to_end/fixtures/routing/contracts/callbacks_direct_bind_c_f90/callbacks_direct_bind_c_f90.pyi index 7de4f6d52..4f7a923c6 100644 --- a/tests/fortran/callbacks/end_to_end/fixtures/routing/contracts/callbacks_direct_bind_c_f90/callbacks_direct_bind_c_f90.pyi +++ b/tests/fortran/callbacks/end_to_end/fixtures/routing/contracts/callbacks_direct_bind_c_f90/callbacks_direct_bind_c_f90.pyi @@ -23,3 +23,5 @@ def direct_call_notify( callback: direct_notify, value: Int32 ) -> None: ... + +__all__ = ["direct_callback", "direct_notify", "direct_apply", "direct_call_notify"] diff --git a/tests/fortran/callbacks/end_to_end/fixtures/routing/contracts/callbacks_mixed_bind_c_f90/callbacks_mixed_bind_c_f90.pyi b/tests/fortran/callbacks/end_to_end/fixtures/routing/contracts/callbacks_mixed_bind_c_f90/callbacks_mixed_bind_c_f90.pyi index cf191fe05..1ec0d480a 100644 --- a/tests/fortran/callbacks/end_to_end/fixtures/routing/contracts/callbacks_mixed_bind_c_f90/callbacks_mixed_bind_c_f90.pyi +++ b/tests/fortran/callbacks/end_to_end/fixtures/routing/contracts/callbacks_mixed_bind_c_f90/callbacks_mixed_bind_c_f90.pyi @@ -22,3 +22,5 @@ def adapted_apply( callback: adapted_callback, value: Float64 ) -> Float64: ... + +__all__ = ["direct_callback", "adapted_callback", "direct_apply", "adapted_apply"] diff --git a/tests/fortran/data_types/end_to_end/fixtures/contracts/fbind_value_f90/fbind_value_f90.pyi b/tests/fortran/data_types/end_to_end/fixtures/contracts/fbind_value_f90/fbind_value_f90.pyi index 8cd319629..5b02667ca 100644 --- a/tests/fortran/data_types/end_to_end/fixtures/contracts/fbind_value_f90/fbind_value_f90.pyi +++ b/tests/fortran/data_types/end_to_end/fixtures/contracts/fbind_value_f90/fbind_value_f90.pyi @@ -40,3 +40,13 @@ def invert_flag( def char_code( ch: String[1] ) -> Int32: ... + +__all__ = [ + "plus_value", + "double_value", + "plus_reference", + "scale_real", + "conjugate_value", + "invert_flag", + "char_code", +] 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 26cc2cc80..677f2e6ae 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 @@ -557,3 +557,91 @@ def is_positive_r8( def is_even_i4( X: Int32 ) -> tuple[Bool32, Returns["X", Int32]]: ... + +__all__ = [ + "square_r4", + "square_r8", + "square_i4", + "square_c4", + "square_c8", + "cube_r4", + "cube_r8", + "cube_i4", + "add_r4", + "add_r8", + "add_i4", + "add_c4", + "add_c8", + "sub_r4", + "sub_r8", + "sub_i4", + "mul_r4", + "mul_r8", + "mul_i4", + "div_r4", + "div_r8", + "pow_r4", + "pow_r8", + "abs_r4", + "abs_r8", + "abs_i4", + "neg_r4", + "neg_r8", + "neg_i4", + "sin_r4", + "sin_r8", + "cos_r4", + "cos_r8", + "tan_r4", + "tan_r8", + "asin_r4", + "asin_r8", + "acos_r4", + "acos_r8", + "atan_r4", + "atan_r8", + "atan2_r4", + "atan2_r8", + "exp_r4", + "exp_r8", + "log_r4", + "log_r8", + "log10_r4", + "log10_r8", + "sqrt_r4", + "sqrt_r8", + "hypot_r4", + "hypot_r8", + "min_r4", + "min_r8", + "min_i4", + "max_r4", + "max_r8", + "max_i4", + "sign_r4", + "sign_r8", + "mod_i4", + "mod_r4", + "mod_r8", + "deg2rad_r4", + "deg2rad_r8", + "rad2deg_r4", + "rad2deg_r8", + "dist2_r4", + "dist2_r8", + "dot2_r4", + "dot2_r8", + "dot3_r4", + "dot3_r8", + "conj_c4", + "conj_c8", + "real_c4", + "real_c8", + "aimag_c4", + "aimag_c8", + "abs_c4", + "abs_c8", + "is_positive_r4", + "is_positive_r8", + "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 8cd058552..38cabcd36 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 @@ -472,3 +472,91 @@ def is_positive_r8( def is_even_i4( X: Int32 ) -> tuple[Bool32, Returns["X", Int32]]: ... + +__all__ = [ + "square_r4", + "square_r8", + "square_i4", + "square_c4", + "square_c8", + "cube_r4", + "cube_r8", + "cube_i4", + "add_r4", + "add_r8", + "add_i4", + "add_c4", + "add_c8", + "sub_r4", + "sub_r8", + "sub_i4", + "mul_r4", + "mul_r8", + "mul_i4", + "div_r4", + "div_r8", + "pow_r4", + "pow_r8", + "abs_r4", + "abs_r8", + "abs_i4", + "neg_r4", + "neg_r8", + "neg_i4", + "sin_r4", + "sin_r8", + "cos_r4", + "cos_r8", + "tan_r4", + "tan_r8", + "asin_r4", + "asin_r8", + "acos_r4", + "acos_r8", + "atan_r4", + "atan_r8", + "atan2_r4", + "atan2_r8", + "exp_r4", + "exp_r8", + "log_r4", + "log_r8", + "log10_r4", + "log10_r8", + "sqrt_r4", + "sqrt_r8", + "hypot_r4", + "hypot_r8", + "min_r4", + "min_r8", + "min_i4", + "max_r4", + "max_r8", + "max_i4", + "sign_r4", + "sign_r8", + "mod_i4", + "mod_r4", + "mod_r8", + "deg2rad_r4", + "deg2rad_r8", + "rad2deg_r4", + "rad2deg_r8", + "dist2_r4", + "dist2_r8", + "dot2_r4", + "dot2_r8", + "dot3_r4", + "dot3_r8", + "conj_c4", + "conj_c8", + "real_c4", + "real_c8", + "aimag_c4", + "aimag_c8", + "abs_c4", + "abs_c8", + "is_positive_r4", + "is_positive_r8", + "is_even_i4", +] diff --git a/tests/fortran/data_types/end_to_end/fixtures/contracts/fscalar_kinds_f90/fscalar_kinds_f90.pyi b/tests/fortran/data_types/end_to_end/fixtures/contracts/fscalar_kinds_f90/fscalar_kinds_f90.pyi index 957e79c19..d982479ca 100644 --- a/tests/fortran/data_types/end_to_end/fixtures/contracts/fscalar_kinds_f90/fscalar_kinds_f90.pyi +++ b/tests/fortran/data_types/end_to_end/fixtures/contracts/fscalar_kinds_f90/fscalar_kinds_f90.pyi @@ -101,3 +101,25 @@ def conj_c_float_complex( def conj_c_double_complex( value: Complex128 ) -> Complex128: ... + +__all__ = [ + "id_i8", + "id_i16", + "id_i32", + "id_i32_value", + "id_i64", + "copy_i16", + "not_flag", + "invert_flags", + "id_r32", + "id_r64", + "copy_r64", + "conj_c64", + "shift_c128", + "copy_c128", + "id_c_i32", + "id_c_float", + "id_c_double", + "conj_c_float_complex", + "conj_c_double_complex", +] diff --git a/tests/fortran/data_types/end_to_end/fixtures/routing/contracts/scalar_direct_bind_c_f90/scalar_direct_bind_c_f90.pyi b/tests/fortran/data_types/end_to_end/fixtures/routing/contracts/scalar_direct_bind_c_f90/scalar_direct_bind_c_f90.pyi index 919b914d1..03cd82a17 100644 --- a/tests/fortran/data_types/end_to_end/fixtures/routing/contracts/scalar_direct_bind_c_f90/scalar_direct_bind_c_f90.pyi +++ b/tests/fortran/data_types/end_to_end/fixtures/routing/contracts/scalar_direct_bind_c_f90/scalar_direct_bind_c_f90.pyi @@ -28,3 +28,5 @@ def invert_flag( def optional_state( value: Float64 = ... ) -> Int32: ... + +__all__ = ["renamed_add", "reference_add", "scale_output", "invert_flag", "optional_state"] diff --git a/tests/fortran/data_types/end_to_end/fixtures/routing/contracts/scalar_mixed_bind_c_f90/scalar_mixed_bind_c_f90.pyi b/tests/fortran/data_types/end_to_end/fixtures/routing/contracts/scalar_mixed_bind_c_f90/scalar_mixed_bind_c_f90.pyi index 6e07ecec9..fb112fcb9 100644 --- a/tests/fortran/data_types/end_to_end/fixtures/routing/contracts/scalar_mixed_bind_c_f90/scalar_mixed_bind_c_f90.pyi +++ b/tests/fortran/data_types/end_to_end/fixtures/routing/contracts/scalar_mixed_bind_c_f90/scalar_mixed_bind_c_f90.pyi @@ -10,3 +10,5 @@ def direct_add( def adapted_add( value: Int32 ) -> Int32: ... + +__all__ = ["direct_add", "adapted_add"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fbind_c_derived_layout_f90/fbind_c_derived_layout_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fbind_c_derived_layout_f90/fbind_c_derived_layout_f90.pyi index 5aef293bf..e1ae5bc15 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fbind_c_derived_layout_f90/fbind_c_derived_layout_f90.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fbind_c_derived_layout_f90/fbind_c_derived_layout_f90.pyi @@ -36,3 +36,5 @@ def populate( def score_by_value( value: tagged_point ) -> Float64: ... + +__all__ = ["point", "tagged_point", "populate", "score_by_value"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fborrowed_finalizer_f90/fborrowed_finalizer_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fborrowed_finalizer_f90/fborrowed_finalizer_f90.pyi index 121fc86a7..b4d583676 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fborrowed_finalizer_f90/fborrowed_finalizer_f90.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fborrowed_finalizer_f90/fborrowed_finalizer_f90.pyi @@ -12,3 +12,5 @@ class parent: def get_final_count() -> Int32: ... def reset_final_count() -> None: ... + +__all__ = ["child", "parent", "get_final_count", "reset_final_count"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fclasses_f90/fclasses_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fclasses_f90/fclasses_f90.pyi index 1577dce2c..0d5a7f68f 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fclasses_f90/fclasses_f90.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fclasses_f90/fclasses_f90.pyi @@ -109,3 +109,16 @@ def make_vector_store( n: Int64, fill_value: Float64 ) -> vector_store: ... + +__all__ = [ + "vector", + "vector_store", + "scale", + "shift_vector", + "magnitude", + "allocate_values", + "set_values", + "allocate_matrix", + "set_matrix", + "make_vector_store", +] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fconstructors_f90/fconstructors_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fconstructors_f90/fconstructors_f90.pyi index 59b81635e..b748e70d9 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fconstructors_f90/fconstructors_f90.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fconstructors_f90/fconstructors_f90.pyi @@ -17,3 +17,5 @@ class initialized: def get_final_count() -> Int32: ... def reset_final_count() -> None: ... + +__all__ = ["initialized", "get_final_count", "reset_final_count"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fderived_boundary_f90/fderived_boundary_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fderived_boundary_f90/fderived_boundary_f90.pyi index 4040def79..4efc42d0c 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fderived_boundary_f90/fderived_boundary_f90.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fderived_boundary_f90/fderived_boundary_f90.pyi @@ -53,3 +53,14 @@ def set_holder_origin( def holder_origin_x( h: holder ) -> Float64: ... + +__all__ = [ + "point", + "holder", + "point_sum", + "move_point", + "make_point_out", + "make_point", + "set_holder_origin", + "holder_origin_x", +] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/finheritance_f90/finheritance_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/finheritance_f90/finheritance_f90.pyi index c4756c18d..0ff445b6f 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/finheritance_f90/finheritance_f90.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/finheritance_f90/finheritance_f90.pyi @@ -64,3 +64,5 @@ def box_area( def describe_shape( item: Annotated[base_shape, Polymorphic] ) -> Float64: ... + +__all__ = ["base_shape", "circle", "box", "base_area", "base_set_size", "circle_area", "box_area", "describe_shape"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fmodule_derived_alias_f90/fmodule_derived_alias_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fmodule_derived_alias_f90/fmodule_derived_alias_f90.pyi index c8ce690a0..32481ec54 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fmodule_derived_alias_f90/fmodule_derived_alias_f90.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fmodule_derived_alias_f90/fmodule_derived_alias_f90.pyi @@ -23,3 +23,5 @@ def allocate_current( def deallocate_current() -> None: ... def current_sum() -> Float64: ... + +__all__ = ["box", "current", "allocate_current", "deallocate_current", "current_sum"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_direct_bind_c_f90/derived_types_direct_bind_c_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_direct_bind_c_f90/derived_types_direct_bind_c_f90.pyi index 9ab5f98eb..bd6c39f8a 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_direct_bind_c_f90/derived_types_direct_bind_c_f90.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_direct_bind_c_f90/derived_types_direct_bind_c_f90.pyi @@ -22,3 +22,5 @@ def direct_shift( value: point, delta: Float64 ) -> None: ... + +__all__ = ["point", "direct_sum", "direct_shift"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_mixed_bind_c_f90/derived_types_mixed_bind_c_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_mixed_bind_c_f90/derived_types_mixed_bind_c_f90.pyi index 3f20c08a5..684d922a3 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_mixed_bind_c_f90/derived_types_mixed_bind_c_f90.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_mixed_bind_c_f90/derived_types_mixed_bind_c_f90.pyi @@ -22,3 +22,5 @@ def direct_sum( def adapted_sum_by_value( value: point ) -> Float64: ... + +__all__ = ["point", "direct_sum", "adapted_sum_by_value"] diff --git a/tests/fortran/enumerations/end_to_end/fixtures/contracts/fenums_f90/fenums_f90.pyi b/tests/fortran/enumerations/end_to_end/fixtures/contracts/fenums_f90/fenums_f90.pyi index 67831c97b..fe0744f65 100644 --- a/tests/fortran/enumerations/end_to_end/fixtures/contracts/fenums_f90/fenums_f90.pyi +++ b/tests/fortran/enumerations/end_to_end/fixtures/contracts/fenums_f90/fenums_f90.pyi @@ -21,3 +21,5 @@ yellow: Final[Int32] = 11 def round_trip_color( color: Int32 ) -> Int32: ... + +__all__ = ["paint", "red", "blue", "green", "yellow", "round_trip_color"] diff --git a/tests/fortran/enumerations/end_to_end/fixtures/routing/contracts/enumerations_direct_bind_c_f90/enumerations_direct_bind_c_f90.pyi b/tests/fortran/enumerations/end_to_end/fixtures/routing/contracts/enumerations_direct_bind_c_f90/enumerations_direct_bind_c_f90.pyi index 9765eb383..77bff3d4d 100644 --- a/tests/fortran/enumerations/end_to_end/fixtures/routing/contracts/enumerations_direct_bind_c_f90/enumerations_direct_bind_c_f90.pyi +++ b/tests/fortran/enumerations/end_to_end/fixtures/routing/contracts/enumerations_direct_bind_c_f90/enumerations_direct_bind_c_f90.pyi @@ -18,3 +18,5 @@ def direct_round_trip( def direct_next( state: Int32 ) -> Int32: ... + +__all__ = ["terminal", "stopped", "ready", "running", "direct_round_trip", "direct_next"] diff --git a/tests/fortran/enumerations/end_to_end/fixtures/routing/contracts/enumerations_mixed_bind_c_f90/enumerations_mixed_bind_c_f90.pyi b/tests/fortran/enumerations/end_to_end/fixtures/routing/contracts/enumerations_mixed_bind_c_f90/enumerations_mixed_bind_c_f90.pyi index 4e1caf98f..25cf80aa3 100644 --- a/tests/fortran/enumerations/end_to_end/fixtures/routing/contracts/enumerations_mixed_bind_c_f90/enumerations_mixed_bind_c_f90.pyi +++ b/tests/fortran/enumerations/end_to_end/fixtures/routing/contracts/enumerations_mixed_bind_c_f90/enumerations_mixed_bind_c_f90.pyi @@ -15,3 +15,5 @@ def direct_round_trip( def adapted_next( state: Int32 ) -> Int32: ... + +__all__ = ["stopped", "ready", "running", "direct_round_trip", "adapted_next"] diff --git a/tests/fortran/error_handling/end_to_end/fixtures/contracts/fopenmp_runtime_f90/fopenmp_runtime_f90.pyi b/tests/fortran/error_handling/end_to_end/fixtures/contracts/fopenmp_runtime_f90/fopenmp_runtime_f90.pyi index f64f6ca2b..a7eace268 100644 --- a/tests/fortran/error_handling/end_to_end/fixtures/contracts/fopenmp_runtime_f90/fopenmp_runtime_f90.pyi +++ b/tests/fortran/error_handling/end_to_end/fixtures/contracts/fopenmp_runtime_f90/fopenmp_runtime_f90.pyi @@ -3,3 +3,5 @@ from prik.contracts import Float64 def parallel_sum( values: Float64[::] ) -> Float64: ... + +__all__ = ["parallel_sum"] diff --git a/tests/fortran/error_handling/end_to_end/fixtures/contracts/fruntime_recursion_f90/fruntime_recursion_f90.pyi b/tests/fortran/error_handling/end_to_end/fixtures/contracts/fruntime_recursion_f90/fruntime_recursion_f90.pyi index b768d70dc..c136269e6 100644 --- a/tests/fortran/error_handling/end_to_end/fixtures/contracts/fruntime_recursion_f90/fruntime_recursion_f90.pyi +++ b/tests/fortran/error_handling/end_to_end/fixtures/contracts/fruntime_recursion_f90/fruntime_recursion_f90.pyi @@ -9,3 +9,5 @@ def factorial( def add_one( n: Int32 ) -> Int32: ... + +__all__ = ["factorial", "add_one"] diff --git a/tests/fortran/functions/end_to_end/fixtures/contracts/basic_subroutine/m1.pyi b/tests/fortran/functions/end_to_end/fixtures/contracts/basic_subroutine/m1.pyi index 96c7bb126..676d8cf7d 100644 --- a/tests/fortran/functions/end_to_end/fixtures/contracts/basic_subroutine/m1.pyi +++ b/tests/fortran/functions/end_to_end/fixtures/contracts/basic_subroutine/m1.pyi @@ -5,3 +5,5 @@ def add1( n: Int32, x: Float64[n] ) -> None: ... + +__all__ = ["add1"] diff --git a/tests/fortran/functions/end_to_end/fixtures/contracts/blas_like/__init__.pyi b/tests/fortran/functions/end_to_end/fixtures/contracts/blas_like/__init__.pyi index 6f3ea33a8..83b9b0ec0 100644 --- a/tests/fortran/functions/end_to_end/fixtures/contracts/blas_like/__init__.pyi +++ b/tests/fortran/functions/end_to_end/fixtures/contracts/blas_like/__init__.pyi @@ -16,3 +16,5 @@ def ddot_like( x: Float64[n], y: Float64[n] ) -> Float64: ... + +__all__ = ["daxpy_like", "ddot_like"] diff --git a/tests/fortran/functions/end_to_end/fixtures/contracts/external_bundle/__init__.pyi b/tests/fortran/functions/end_to_end/fixtures/contracts/external_bundle/__init__.pyi index e9a67a5d4..d85ff606f 100644 --- a/tests/fortran/functions/end_to_end/fixtures/contracts/external_bundle/__init__.pyi +++ b/tests/fortran/functions/end_to_end/fixtures/contracts/external_bundle/__init__.pyi @@ -11,3 +11,5 @@ def triple_value( def offset_value( value: Int32 ) -> Int32: ... + +__all__ = ["triple_value", "offset_value"] diff --git a/tests/fortran/functions/end_to_end/fixtures/contracts/fixed_external/__init__.pyi b/tests/fortran/functions/end_to_end/fixtures/contracts/fixed_external/__init__.pyi index ad7eef082..800ca05af 100644 --- a/tests/fortran/functions/end_to_end/fixtures/contracts/fixed_external/__init__.pyi +++ b/tests/fortran/functions/end_to_end/fixtures/contracts/fixed_external/__init__.pyi @@ -5,3 +5,5 @@ from prik.contracts import Addr, Arg, Int32, Returns, native_call, standalone def fixed_add( value: Int32 ) -> tuple[Int32, Returns["value", Int32]]: ... + +__all__ = ["fixed_add"] diff --git a/tests/fortran/functions/end_to_end/fixtures/contracts/free_external/__init__.pyi b/tests/fortran/functions/end_to_end/fixtures/contracts/free_external/__init__.pyi index 3468b30b6..7ffc47620 100644 --- a/tests/fortran/functions/end_to_end/fixtures/contracts/free_external/__init__.pyi +++ b/tests/fortran/functions/end_to_end/fixtures/contracts/free_external/__init__.pyi @@ -5,3 +5,5 @@ from prik.contracts import Addr, Arg, Int32, native_call, standalone def free_square( value: Int32 ) -> Int32: ... + +__all__ = ["free_square"] diff --git a/tests/fortran/functions/end_to_end/fixtures/routing/contracts/standalone_direct_bind_c_f90/__init__.pyi b/tests/fortran/functions/end_to_end/fixtures/routing/contracts/standalone_direct_bind_c_f90/__init__.pyi index 8aa3d1f26..741e47720 100644 --- a/tests/fortran/functions/end_to_end/fixtures/routing/contracts/standalone_direct_bind_c_f90/__init__.pyi +++ b/tests/fortran/functions/end_to_end/fixtures/routing/contracts/standalone_direct_bind_c_f90/__init__.pyi @@ -13,3 +13,5 @@ def standalone_direct( def standalone_output( value: Int32 ) -> Int32: ... + +__all__ = ["standalone_direct", "standalone_output"] diff --git a/tests/fortran/functions/end_to_end/fixtures/routing/contracts/standalone_mixed_bind_c_f90/__init__.pyi b/tests/fortran/functions/end_to_end/fixtures/routing/contracts/standalone_mixed_bind_c_f90/__init__.pyi index c0e58fd2f..15e28dad1 100644 --- a/tests/fortran/functions/end_to_end/fixtures/routing/contracts/standalone_mixed_bind_c_f90/__init__.pyi +++ b/tests/fortran/functions/end_to_end/fixtures/routing/contracts/standalone_mixed_bind_c_f90/__init__.pyi @@ -12,3 +12,5 @@ def standalone_direct( def standalone_adapted( value: Int32 ) -> Int32: ... + +__all__ = ["standalone_direct", "standalone_adapted"] diff --git a/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foperators_f90/foperators_f90.pyi b/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foperators_f90/foperators_f90.pyi index 24c5606e0..ad8915f2d 100644 --- a/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foperators_f90/foperators_f90.pyi +++ b/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foperators_f90/foperators_f90.pyi @@ -445,3 +445,5 @@ def convert( def convert( value: Float64 ) -> Float64: ... + +__all__ = ["vector", "offset", "counter", "convert"] diff --git a/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_f90/foverloads_f90.pyi b/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_f90/foverloads_f90.pyi index 21cfdf8e1..06b568d68 100644 --- a/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_f90/foverloads_f90.pyi +++ b/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_f90/foverloads_f90.pyi @@ -142,3 +142,5 @@ def inspect( def inspect( value: sample ) -> Float64: ... + +__all__ = ["accumulator", "sample", "convert", "summarize", "inspect"] diff --git a/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_fixed/foverloads_fixed.pyi b/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_fixed/foverloads_fixed.pyi index 500b66162..8b4c0ff71 100644 --- a/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_fixed/foverloads_fixed.pyi +++ b/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_fixed/foverloads_fixed.pyi @@ -23,3 +23,5 @@ def convert( def convert( value: Float64 ) -> Float64: ... + +__all__ = ["convert"] diff --git a/tests/fortran/generic_interfaces/end_to_end/fixtures/routing/contracts/generic_interfaces_direct_bind_c_f90/generic_interfaces_direct_bind_c_f90.pyi b/tests/fortran/generic_interfaces/end_to_end/fixtures/routing/contracts/generic_interfaces_direct_bind_c_f90/generic_interfaces_direct_bind_c_f90.pyi index 4452092ff..09b1529a2 100644 --- a/tests/fortran/generic_interfaces/end_to_end/fixtures/routing/contracts/generic_interfaces_direct_bind_c_f90/generic_interfaces_direct_bind_c_f90.pyi +++ b/tests/fortran/generic_interfaces/end_to_end/fixtures/routing/contracts/generic_interfaces_direct_bind_c_f90/generic_interfaces_direct_bind_c_f90.pyi @@ -53,3 +53,5 @@ def increment( def increment( value: Float64 ) -> Returns["value", Float64]: ... + +__all__ = ["convert_integer", "convert_real", "increment_integer", "increment_real", "convert", "increment"] diff --git a/tests/fortran/generic_interfaces/end_to_end/fixtures/routing/contracts/generic_interfaces_mixed_bind_c_f90/generic_interfaces_mixed_bind_c_f90.pyi b/tests/fortran/generic_interfaces/end_to_end/fixtures/routing/contracts/generic_interfaces_mixed_bind_c_f90/generic_interfaces_mixed_bind_c_f90.pyi index 9cfbeffba..cbc88bea6 100644 --- a/tests/fortran/generic_interfaces/end_to_end/fixtures/routing/contracts/generic_interfaces_mixed_bind_c_f90/generic_interfaces_mixed_bind_c_f90.pyi +++ b/tests/fortran/generic_interfaces/end_to_end/fixtures/routing/contracts/generic_interfaces_mixed_bind_c_f90/generic_interfaces_mixed_bind_c_f90.pyi @@ -22,3 +22,5 @@ def convert( def convert( value: Float64 ) -> Float64: ... + +__all__ = ["convert_integer", "convert_real", "convert"] 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 5fcc92471..9f2e286ec 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 @@ -4,3 +4,5 @@ from .shared_types import box def box_value( item: box ) -> Int32: ... + +__all__ = ["box_value"] diff --git a/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/first_math.pyi b/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/first_math.pyi index 6f66faa97..cc965ca9b 100644 --- a/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/first_math.pyi +++ b/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/first_math.pyi @@ -4,3 +4,5 @@ from prik.contracts import Addr, Arg, Int32, native_call def add_one( value: Int32 ) -> Int32: ... + +__all__ = ["add_one"] 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 bcb952886..8a7cf6b13 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 @@ -5,3 +5,5 @@ from .first_math import add_one def double_after_add( value: Int32 ) -> Int32: ... + +__all__ = ["double_after_add"] diff --git a/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/shared_types.pyi b/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/shared_types.pyi index 46068bb75..411244aa4 100644 --- a/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/shared_types.pyi +++ b/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/shared_types.pyi @@ -13,3 +13,5 @@ class box: def make_box( value: Int32 ) -> box: ... + +__all__ = ["box", "make_box"] diff --git a/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/runtime_abi/fruntime_abi_f90.pyi b/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/runtime_abi/fruntime_abi_f90.pyi index fd324298a..48a2a532e 100644 --- a/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/runtime_abi/fruntime_abi_f90.pyi +++ b/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/runtime_abi/fruntime_abi_f90.pyi @@ -5,3 +5,5 @@ def scale( value: Float64, factor: Float64 ) -> Float64: ... + +__all__ = ["scale"] diff --git a/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/fdefault_output/__init__.pyi b/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/fdefault_output/__init__.pyi index e191cf56a..9a8bc393e 100644 --- a/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/fdefault_output/__init__.pyi +++ b/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/fdefault_output/__init__.pyi @@ -5,3 +5,5 @@ from prik.contracts import Addr, Arg, Int32, Returns, native_call, standalone def add_one( value: Int32 ) -> tuple[Int32, Returns["value", Int32]]: ... + +__all__ = ["add_one"] diff --git a/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/fruntime_abi_f90/fruntime_abi_f90.pyi b/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/fruntime_abi_f90/fruntime_abi_f90.pyi index fd324298a..48a2a532e 100644 --- a/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/fruntime_abi_f90/fruntime_abi_f90.pyi +++ b/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/fruntime_abi_f90/fruntime_abi_f90.pyi @@ -5,3 +5,5 @@ def scale( value: Float64, factor: Float64 ) -> Float64: ... + +__all__ = ["scale"] diff --git a/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/verbose_api/verbose_api.pyi b/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/verbose_api/verbose_api.pyi index 824579ad3..abe0482e9 100644 --- a/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/verbose_api/verbose_api.pyi +++ b/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/verbose_api/verbose_api.pyi @@ -1 +1,3 @@ def ping() -> None: ... + +__all__ = ["ping"] diff --git a/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py b/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py index be2e7e550..529903d7a 100644 --- a/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py +++ b/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py @@ -320,7 +320,7 @@ def test_prik_pyi_report_writes_opaque_dependency_stub_for_external_type(tmp_pat payload = prik_cli._semantic_report([str(physics)]) assert payload[str(physics)]["pyi_dependencies"] == { - "types_mod": "from prik.contracts import Opaque\n\nclass particle(Opaque):\n pass" + "types_mod": 'from prik.contracts import Opaque\n\nclass particle(Opaque):\n pass\n\n__all__ = ["particle"]' } monkeypatch.setattr(sys, "argv", ["prik", "generate", "--pyi", str(physics), "--out"]) assert prik_cli.main() == 0 @@ -329,7 +329,7 @@ def test_prik_pyi_report_writes_opaque_dependency_stub_for_external_type(tmp_pat assert (package / "__init__.pyi").read_text(encoding="utf-8") == "from . import physics\n" assert (package / "types_mod.pyi").read_text( encoding="utf-8" - ) == "from prik.contracts import Opaque\n\nclass particle(Opaque):\n pass\n" + ) == 'from prik.contracts import Opaque\n\nclass particle(Opaque):\n pass\n\n__all__ = ["particle"]\n' @pytest.mark.parametrize( diff --git a/tests/fortran/infrastructure/jupyter/end_to_end/test_fortran_magic_runtime.py b/tests/fortran/infrastructure/jupyter/end_to_end/test_fortran_magic_runtime.py index 7c6c0e76b..26073a378 100644 --- a/tests/fortran/infrastructure/jupyter/end_to_end/test_fortran_magic_runtime.py +++ b/tests/fortran/infrastructure/jupyter/end_to_end/test_fortran_magic_runtime.py @@ -139,6 +139,8 @@ def counting_build(*args, **kwargs): ) contract = contract.replace("@native_call", '@bind("square")\n@native_call') contract = contract.replace("def square(", "def squared(") + # Renaming a declaration renames what the contract publishes. + contract = contract.replace('__all__ = ["square"]', '__all__ = ["squared"]') line = magic_line.removeprefix("%%pyi").strip() shell.run_cell_magic("pyi", line, contract) 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 57e8c192f..f87810960 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 @@ -1,257 +1,258 @@ { "semantic_modules": [ { - "name": "m1", + "classes": [], + "exported_names": null, "functions": [ { - "name": "add1", - "native_name": "add1", "arguments": [ { + "default_value": null, + "metadata": {}, "name": "n", + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "n", + "native_scope": "add1", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": { - "kind": "reference", - "read_only": true, - "mutable": false, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "n", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } + }, + "native_abi": null, + "native_name": "n", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": true } }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "x", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "n", - "native_abi": null, - "native_symbol": null, - "native_scope": "add1", - "source_kind": "argument", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [ + "1" + ], "optional": false, + "pointer": false, + "rank": 1, + "shape": [ + "n" + ], + "target": false, + "upper_bounds": [ + "n" + ], "value": false - } + }, + "native_abi": null, + "native_name": "x", + "native_scope": "add1", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" }, - "optional": false - }, - { - "name": "x", "semantic_type": { - "name": "Float64", - "rank": 1, - "dtype": "Float64", - "shape": [ - "n" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, + "constraints": [], + "dtype": "Float64", "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": true, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { + "name": "Float64", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ "n" ], - "lower_bounds": [], - "upper_bounds": [], - "source_shape": [ + "target": false, + "upper_bounds": [ "n" ], - "category": "explicit_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" - ], - "contiguous": true, - "allocatable": false, - "pointer": false, - "metadata": {} + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "x", "native_abi": null, - "native_symbol": null, + "native_name": "x", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "real(kind=8)", + "source_language": "fortran", "source_location": {}, - "metadata": { + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "n" + ], + "storage": { + "array": { + "allocatable": false, + "axes": [ + "dense" + ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, + "pointer": false, "rank": 1, "shape": [ "n" ], - "lower_bounds": [ - "1" - ], - "upper_bounds": [ + "source_shape": [ "n" ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } - }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "x", - "native_abi": null, - "native_symbol": null, - "native_scope": "add1", - "source_kind": "argument", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 1, - "shape": [ - "n" - ], - "lower_bounds": [ - "1" - ], - "upper_bounds": [ - "n" - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false + "upper_bounds": [] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "optional": false + "visibility": "public" } ], - "return_type": null, - "locals": [], "contracts": [], + "locals": [], + "metadata": {}, + "name": "add1", + "native_name": "add1", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "add1", + "native_scope": "m1", + "native_symbol": null, + "source_kind": "subroutine", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, "projection": [ { - "python_name": "n", + "native_c_identity": null, "native_name": "n", "native_position": 0, + "python_name": "n", "python_position": 0, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" }, { - "python_name": "x", + "native_c_identity": null, "native_name": "x", "native_position": 1, + "python_name": "x", "python_position": 1, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" } ], - "metadata": {}, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "add1", - "native_abi": null, - "native_symbol": null, - "native_scope": "m1", - "source_kind": "subroutine", - "source_type": null, - "source_location": {}, - "metadata": {} - } + "return_type": null, + "visibility": "public" } ], - "reexports": [], - "prototypes": [], - "overload_sets": [], - "classes": [], - "variables": [], "imports": [], "metadata": {}, + "name": "m1", "origin": { - "source_language": "fortran", - "native_name": "m1", + "metadata": {}, "native_abi": null, - "native_symbol": null, + "native_name": "m1", "native_scope": "m1", + "native_symbol": null, "source_kind": "module", - "source_type": null, + "source_language": "fortran", "source_location": {}, - "metadata": {} - } + "source_type": null + }, + "overload_sets": [], + "prototypes": [], + "reexports": [], + "variables": [] } ] } 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 cd59851b8..4978c5c17 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 @@ -1,1818 +1,1819 @@ { "semantic_modules": [ { - "name": "expr_mod", + "classes": [], + "exported_names": null, "functions": [ { - "name": "all_exprs", - "native_name": "all_exprs", "arguments": [ { + "default_value": null, + "metadata": {}, "name": "x1", + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, + "rank": 1, + "shape": [ + "1:p_add" + ], + "target": false, + "upper_bounds": [ + "p_add" + ], + "value": false + }, + "native_abi": null, + "native_name": "x1", + "native_scope": "all_exprs", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, "semantic_type": { - "name": "Int32", - "rank": 1, - "dtype": "Int32", - "shape": [ - "p_add" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": true, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { + "name": "Int32", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ - "p_add" + "1:p_add" ], - "lower_bounds": [], + "target": false, "upper_bounds": [ "p_add" ], - "source_shape": [ - "1:p_add" - ], - "category": "explicit_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" - ], - "contiguous": true, - "allocatable": false, - "pointer": false, - "metadata": {} + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "x1", "native_abi": null, - "native_symbol": null, + "native_name": "x1", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "integer", + "source_language": "fortran", "source_location": {}, - "metadata": { + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "p_add" + ], + "storage": { + "array": { + "allocatable": false, + "axes": [ + "dense" + ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, + "pointer": false, "rank": 1, "shape": [ - "1:p_add" + "p_add" ], - "lower_bounds": [ - "1" + "source_shape": [ + "1:p_add" ], "upper_bounds": [ "p_add" - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + ] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "x2", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "x1", - "native_abi": null, - "native_symbol": null, - "native_scope": "all_exprs", - "source_kind": "argument", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 1, - "shape": [ - "1:p_add" - ], + "allocatable": false, + "contiguous": false, "lower_bounds": [ "1" ], - "upper_bounds": [ - "p_add" - ], - "allocatable": false, + "optional": false, "pointer": false, + "rank": 1, + "shape": [ + "1:p_sub" + ], "target": false, - "contiguous": false, - "optional": false, + "upper_bounds": [ + "p_sub" + ], "value": false - } + }, + "native_abi": null, + "native_name": "x2", + "native_scope": "all_exprs", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" }, - "optional": false - }, - { - "name": "x2", "semantic_type": { - "name": "Int32", - "rank": 1, - "dtype": "Int32", - "shape": [ - "p_sub" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": true, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { + "name": "Int32", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ - "p_sub" + "1:p_sub" ], - "lower_bounds": [], + "target": false, "upper_bounds": [ "p_sub" ], - "source_shape": [ - "1:p_sub" - ], - "category": "explicit_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" - ], - "contiguous": true, - "allocatable": false, - "pointer": false, - "metadata": {} + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "x2", "native_abi": null, - "native_symbol": null, + "native_name": "x2", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "integer", + "source_language": "fortran", "source_location": {}, - "metadata": { + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "p_sub" + ], + "storage": { + "array": { + "allocatable": false, + "axes": [ + "dense" + ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, + "pointer": false, "rank": 1, "shape": [ - "1:p_sub" + "p_sub" ], - "lower_bounds": [ - "1" + "source_shape": [ + "1:p_sub" ], "upper_bounds": [ "p_sub" - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + ] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "x3", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "x2", - "native_abi": null, - "native_symbol": null, - "native_scope": "all_exprs", - "source_kind": "argument", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 1, - "shape": [ - "1:p_sub" - ], + "allocatable": false, + "contiguous": false, "lower_bounds": [ "1" ], - "upper_bounds": [ - "p_sub" - ], - "allocatable": false, + "optional": false, "pointer": false, + "rank": 1, + "shape": [ + "1:p_mul" + ], "target": false, - "contiguous": false, - "optional": false, + "upper_bounds": [ + "p_mul" + ], "value": false - } + }, + "native_abi": null, + "native_name": "x3", + "native_scope": "all_exprs", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" }, - "optional": false - }, - { - "name": "x3", "semantic_type": { - "name": "Int32", - "rank": 1, - "dtype": "Int32", - "shape": [ - "p_mul" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": true, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { + "name": "Int32", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ - "p_mul" + "1:p_mul" ], - "lower_bounds": [], + "target": false, "upper_bounds": [ "p_mul" ], - "source_shape": [ - "1:p_mul" - ], - "category": "explicit_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" - ], - "contiguous": true, - "allocatable": false, - "pointer": false, - "metadata": {} + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "x3", "native_abi": null, - "native_symbol": null, + "native_name": "x3", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "integer", + "source_language": "fortran", "source_location": {}, - "metadata": { + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "p_mul" + ], + "storage": { + "array": { + "allocatable": false, + "axes": [ + "dense" + ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, + "pointer": false, "rank": 1, "shape": [ - "1:p_mul" + "p_mul" ], - "lower_bounds": [ - "1" + "source_shape": [ + "1:p_mul" ], "upper_bounds": [ "p_mul" - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + ] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "x4", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "x3", - "native_abi": null, - "native_symbol": null, - "native_scope": "all_exprs", - "source_kind": "argument", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 1, - "shape": [ - "1:p_mul" - ], + "allocatable": false, + "contiguous": false, "lower_bounds": [ "1" ], - "upper_bounds": [ - "p_mul" - ], - "allocatable": false, + "optional": false, "pointer": false, + "rank": 1, + "shape": [ + "1:p_div" + ], "target": false, - "contiguous": false, - "optional": false, + "upper_bounds": [ + "p_div" + ], "value": false - } + }, + "native_abi": null, + "native_name": "x4", + "native_scope": "all_exprs", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" }, - "optional": false - }, - { - "name": "x4", "semantic_type": { + "coercions": [], + "constraints": [], + "dtype": "Int32", + "metadata": {}, "name": "Int32", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, + "rank": 1, + "shape": [ + "1:p_div" + ], + "target": false, + "upper_bounds": [ + "p_div" + ], + "value": false + }, + "native_abi": null, + "native_name": "x4", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, "rank": 1, - "dtype": "Int32", "shape": [ "p_div" ], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, - "metadata": {}, "storage": { - "kind": "array", - "read_only": false, - "mutable": true, - "pointer_depth": 0, - "ownership": "borrowed", "array": { - "rank": 1, - "shape": [ - "p_div" + "allocatable": false, + "axes": [ + "dense" ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, "lower_bounds": [], - "upper_bounds": [ + "metadata": {}, + "order": null, + "pointer": false, + "rank": 1, + "shape": [ "p_div" ], "source_shape": [ "1:p_div" ], - "category": "explicit_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" - ], - "contiguous": true, - "allocatable": false, - "pointer": false, - "metadata": {} - }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "x4", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, - "metadata": { - "rank": 1, - "shape": [ - "1:p_div" - ], - "lower_bounds": [ - "1" - ], "upper_bounds": [ "p_div" - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + ] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "x5", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "x4", - "native_abi": null, - "native_symbol": null, - "native_scope": "all_exprs", - "source_kind": "argument", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 1, - "shape": [ - "1:p_div" - ], + "allocatable": false, + "contiguous": false, "lower_bounds": [ "1" ], - "upper_bounds": [ - "p_div" - ], - "allocatable": false, + "optional": false, "pointer": false, + "rank": 1, + "shape": [ + "1:p_pow" + ], "target": false, - "contiguous": false, - "optional": false, + "upper_bounds": [ + "p_pow" + ], "value": false - } + }, + "native_abi": null, + "native_name": "x5", + "native_scope": "all_exprs", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" }, - "optional": false - }, - { - "name": "x5", "semantic_type": { - "name": "Int32", - "rank": 1, - "dtype": "Int32", - "shape": [ - "p_pow" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": true, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { + "name": "Int32", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ - "p_pow" + "1:p_pow" ], - "lower_bounds": [], + "target": false, "upper_bounds": [ "p_pow" ], - "source_shape": [ - "1:p_pow" - ], - "category": "explicit_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" - ], - "contiguous": true, - "allocatable": false, - "pointer": false, - "metadata": {} + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "x5", "native_abi": null, - "native_symbol": null, + "native_name": "x5", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "integer", + "source_language": "fortran", "source_location": {}, - "metadata": { + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "p_pow" + ], + "storage": { + "array": { + "allocatable": false, + "axes": [ + "dense" + ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, + "pointer": false, "rank": 1, "shape": [ - "1:p_pow" + "p_pow" ], - "lower_bounds": [ - "1" + "source_shape": [ + "1:p_pow" ], "upper_bounds": [ "p_pow" - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + ] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "x6", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "x5", - "native_abi": null, - "native_symbol": null, - "native_scope": "all_exprs", - "source_kind": "argument", - "source_type": "integer", - "source_location": {}, "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "0" + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ - "1:p_pow" - ], - "lower_bounds": [ - "1" + "0:p_mix" ], + "target": false, "upper_bounds": [ - "p_pow" + "p_mix" ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, "value": false - } + }, + "native_abi": null, + "native_name": "x6", + "native_scope": "all_exprs", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" }, - "optional": false - }, - { - "name": "x6", "semantic_type": { - "name": "Int32", - "rank": 1, - "dtype": "Int32", - "shape": [ - "p_mix + 1" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": true, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { - "rank": 1, - "shape": [ - "p_mix + 1" - ], + "name": "Int32", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, "lower_bounds": [ "0" ], + "optional": false, + "pointer": false, + "rank": 1, + "shape": [ + "0:p_mix" + ], + "target": false, "upper_bounds": [ "p_mix" ], - "source_shape": [ - "0:p_mix" - ], - "category": "explicit_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" - ], - "contiguous": true, - "allocatable": false, - "pointer": false, - "metadata": {} + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "x6", "native_abi": null, - "native_symbol": null, + "native_name": "x6", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "integer", + "source_language": "fortran", "source_location": {}, - "metadata": { - "rank": 1, - "shape": [ - "0:p_mix" + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "p_mix + 1" + ], + "storage": { + "array": { + "allocatable": false, + "axes": [ + "dense" ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, "lower_bounds": [ "0" ], + "metadata": {}, + "order": null, + "pointer": false, + "rank": 1, + "shape": [ + "p_mix + 1" + ], + "source_shape": [ + "0:p_mix" + ], "upper_bounds": [ "p_mix" - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + ] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "x7", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "x6", - "native_abi": null, - "native_symbol": null, - "native_scope": "all_exprs", - "source_kind": "argument", - "source_type": "integer", - "source_location": {}, "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ - "0:p_mix" - ], - "lower_bounds": [ - "0" + "1:-(-a + b)" ], + "target": false, "upper_bounds": [ - "p_mix" + "-(-a + b)" ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, "value": false - } + }, + "native_abi": null, + "native_name": "x7", + "native_scope": "all_exprs", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" }, - "optional": false - }, - { - "name": "x7", "semantic_type": { - "name": "Int32", - "rank": 1, - "dtype": "Int32", - "shape": [ - "a - b" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": true, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { + "name": "Int32", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ - "a - b" + "1:-(-a + b)" ], - "lower_bounds": [], + "target": false, "upper_bounds": [ "-(-a + b)" ], - "source_shape": [ - "1:-(-a + b)" - ], - "category": "explicit_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" - ], - "contiguous": true, - "allocatable": false, - "pointer": false, - "metadata": {} + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "x7", "native_abi": null, - "native_symbol": null, + "native_name": "x7", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "integer", + "source_language": "fortran", "source_location": {}, - "metadata": { + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "a - b" + ], + "storage": { + "array": { + "allocatable": false, + "axes": [ + "dense" + ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, + "pointer": false, "rank": 1, "shape": [ - "1:-(-a + b)" + "a - b" ], - "lower_bounds": [ - "1" + "source_shape": [ + "1:-(-a + b)" ], "upper_bounds": [ "-(-a + b)" - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + ] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "x8", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "x7", - "native_abi": null, - "native_symbol": null, - "native_scope": "all_exprs", - "source_kind": "argument", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 1, - "shape": [ - "1:-(-a + b)" - ], + "allocatable": false, + "contiguous": false, "lower_bounds": [ "1" ], - "upper_bounds": [ - "-(-a + b)" - ], - "allocatable": false, + "optional": false, "pointer": false, + "rank": 1, + "shape": [ + "1:(a+b)*(c+1)-1" + ], "target": false, - "contiguous": false, - "optional": false, + "upper_bounds": [ + "(a+b)*(c+1)-1" + ], "value": false - } + }, + "native_abi": null, + "native_name": "x8", + "native_scope": "all_exprs", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" }, - "optional": false - }, - { - "name": "x8", "semantic_type": { - "name": "Int32", - "rank": 1, - "dtype": "Int32", - "shape": [ - "(a + b) * (c + 1) - 1" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": true, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { + "name": "Int32", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ - "(a + b) * (c + 1) - 1" + "1:(a+b)*(c+1)-1" ], - "lower_bounds": [], + "target": false, "upper_bounds": [ "(a+b)*(c+1)-1" ], - "source_shape": [ - "1:(a+b)*(c+1)-1" - ], - "category": "explicit_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" - ], - "contiguous": true, - "allocatable": false, - "pointer": false, - "metadata": {} + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "x8", "native_abi": null, - "native_symbol": null, + "native_name": "x8", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "integer", + "source_language": "fortran", "source_location": {}, - "metadata": { + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "(a + b) * (c + 1) - 1" + ], + "storage": { + "array": { + "allocatable": false, + "axes": [ + "dense" + ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, + "pointer": false, "rank": 1, "shape": [ - "1:(a+b)*(c+1)-1" + "(a + b) * (c + 1) - 1" ], - "lower_bounds": [ - "1" + "source_shape": [ + "1:(a+b)*(c+1)-1" ], "upper_bounds": [ "(a+b)*(c+1)-1" - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + ] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "x9", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "x8", - "native_abi": null, - "native_symbol": null, - "native_scope": "all_exprs", - "source_kind": "argument", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 1, - "shape": [ - "1:(a+b)*(c+1)-1" - ], + "allocatable": false, + "contiguous": false, "lower_bounds": [ "1" ], - "upper_bounds": [ - "(a+b)*(c+1)-1" - ], - "allocatable": false, + "optional": false, "pointer": false, + "rank": 1, + "shape": [ + "1:(a-b)*(a-c)" + ], "target": false, - "contiguous": false, - "optional": false, + "upper_bounds": [ + "(a-b)*(a-c)" + ], "value": false - } + }, + "native_abi": null, + "native_name": "x9", + "native_scope": "all_exprs", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" }, - "optional": false - }, - { - "name": "x9", "semantic_type": { - "name": "Int32", - "rank": 1, - "dtype": "Int32", - "shape": [ - "(a - b) * (a - c)" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": true, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { + "name": "Int32", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ - "(a - b) * (a - c)" + "1:(a-b)*(a-c)" ], - "lower_bounds": [], + "target": false, "upper_bounds": [ "(a-b)*(a-c)" ], - "source_shape": [ - "1:(a-b)*(a-c)" - ], - "category": "explicit_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" - ], - "contiguous": true, - "allocatable": false, - "pointer": false, - "metadata": {} + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "x9", "native_abi": null, - "native_symbol": null, + "native_name": "x9", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "integer", + "source_language": "fortran", "source_location": {}, - "metadata": { + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "(a - b) * (a - c)" + ], + "storage": { + "array": { + "allocatable": false, + "axes": [ + "dense" + ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, + "pointer": false, "rank": 1, "shape": [ - "1:(a-b)*(a-c)" + "(a - b) * (a - c)" ], - "lower_bounds": [ - "1" + "source_shape": [ + "1:(a-b)*(a-c)" ], "upper_bounds": [ "(a-b)*(a-c)" - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } - }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "x9", - "native_abi": null, - "native_symbol": null, - "native_scope": "all_exprs", - "source_kind": "argument", - "source_type": "integer", - "source_location": {}, - "metadata": { - "rank": 1, - "shape": [ - "1:(a-b)*(a-c)" - ], - "lower_bounds": [ - "1" - ], - "upper_bounds": [ - "(a-b)*(a-c)" - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false + ] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "optional": false + "visibility": "public" } ], - "return_type": null, - "locals": [], "contracts": [], - "projection": [ - { - "python_name": "x1", - "native_name": "x1", - "native_position": 0, - "python_position": 0, + "locals": [], + "metadata": {}, + "name": "all_exprs", + "native_name": "all_exprs", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "all_exprs", + "native_scope": "expr_mod", + "native_symbol": null, + "source_kind": "subroutine", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, + "projection": [ + { + "native_c_identity": null, + "native_name": "x1", + "native_position": 0, + "python_name": "x1", + "python_position": 0, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" }, { - "python_name": "x2", + "native_c_identity": null, "native_name": "x2", "native_position": 1, + "python_name": "x2", "python_position": 1, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" }, { - "python_name": "x3", + "native_c_identity": null, "native_name": "x3", "native_position": 2, + "python_name": "x3", "python_position": 2, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" }, { - "python_name": "x4", + "native_c_identity": null, "native_name": "x4", "native_position": 3, + "python_name": "x4", "python_position": 3, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" }, { - "python_name": "x5", + "native_c_identity": null, "native_name": "x5", "native_position": 4, + "python_name": "x5", "python_position": 4, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" }, { - "python_name": "x6", + "native_c_identity": null, "native_name": "x6", "native_position": 5, + "python_name": "x6", "python_position": 5, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" }, { - "python_name": "x7", + "native_c_identity": null, "native_name": "x7", "native_position": 6, + "python_name": "x7", "python_position": 6, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" }, { - "python_name": "x8", + "native_c_identity": null, "native_name": "x8", "native_position": 7, + "python_name": "x8", "python_position": 7, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" }, { - "python_name": "x9", + "native_c_identity": null, "native_name": "x9", "native_position": 8, + "python_name": "x9", "python_position": 8, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" } ], - "metadata": {}, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "all_exprs", - "native_abi": null, - "native_symbol": null, - "native_scope": "expr_mod", - "source_kind": "subroutine", - "source_type": null, - "source_location": {}, - "metadata": {} - } + "return_type": null, + "visibility": "public" } ], - "reexports": [], - "prototypes": [], + "imports": [], + "metadata": {}, + "name": "expr_mod", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "expr_mod", + "native_scope": "expr_mod", + "native_symbol": null, + "source_kind": "module", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, "overload_sets": [], - "classes": [], + "prototypes": [], + "reexports": [], "variables": [ { - "name": "a", - "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], - "constraints": [ - { - "name": "Constant", - "arguments": [] - } - ], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, - "metadata": {}, - "storage": null, - "origin": { - "source_language": "fortran", - "native_name": "a", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false, - "constant": true - } - } - }, - "visibility": "public", "default_value": "8", "metadata": { "fortran_initializer": "8" }, + "name": "a", "origin": { - "source_language": "fortran", - "native_name": "a", - "native_abi": null, - "native_symbol": null, - "native_scope": "expr_mod", - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } - }, - { - "name": "b", + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "a", + "native_scope": "expr_mod", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], + "coercions": [], "constraints": [ { - "name": "Constant", - "arguments": [] + "arguments": [], + "name": "Constant" } ], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "dtype": "Int32", "metadata": {}, - "storage": null, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "b", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "a", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": "3", "metadata": { "fortran_initializer": "3" }, + "name": "b", "origin": { - "source_language": "fortran", - "native_name": "b", - "native_abi": null, - "native_symbol": null, - "native_scope": "expr_mod", - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } - }, - { - "name": "c", + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "b", + "native_scope": "expr_mod", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], + "coercions": [], "constraints": [ { - "name": "Constant", - "arguments": [] + "arguments": [], + "name": "Constant" } ], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "dtype": "Int32", "metadata": {}, - "storage": null, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "c", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } - }, - "visibility": "public", - "default_value": "2", - "metadata": { - "fortran_initializer": "2" - }, - "origin": { - "source_language": "fortran", - "native_name": "c", - "native_abi": null, - "native_symbol": null, - "native_scope": "expr_mod", - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "b", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null + }, + "visibility": "public" + }, + { + "default_value": "2", + "metadata": { + "fortran_initializer": "2" + }, + "name": "c", + "origin": { + "metadata": { "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } - }, - { - "name": "p_add", + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "c", + "native_scope": "expr_mod", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], + "coercions": [], "constraints": [ { - "name": "Constant", - "arguments": [] + "arguments": [], + "name": "Constant" } ], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "dtype": "Int32", "metadata": {}, - "storage": null, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "p_add", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "c", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": "11", "metadata": { "fortran_initializer": "a + b" }, + "name": "p_add", "origin": { - "source_language": "fortran", - "native_name": "p_add", - "native_abi": null, - "native_symbol": null, - "native_scope": "expr_mod", - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } - }, - { - "name": "p_sub", + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "p_add", + "native_scope": "expr_mod", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], + "coercions": [], "constraints": [ { - "name": "Constant", - "arguments": [] + "arguments": [], + "name": "Constant" } ], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "dtype": "Int32", "metadata": {}, - "storage": null, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "p_sub", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "p_add", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": "5", "metadata": { "fortran_initializer": "a - b" }, + "name": "p_sub", "origin": { - "source_language": "fortran", - "native_name": "p_sub", - "native_abi": null, - "native_symbol": null, - "native_scope": "expr_mod", - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } - }, - { - "name": "p_mul", + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "p_sub", + "native_scope": "expr_mod", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], + "coercions": [], "constraints": [ { - "name": "Constant", - "arguments": [] + "arguments": [], + "name": "Constant" } ], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "dtype": "Int32", "metadata": {}, - "storage": null, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "p_mul", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } - }, - "visibility": "public", - "default_value": "6", - "metadata": { - "fortran_initializer": "b * c" - }, - "origin": { - "source_language": "fortran", - "native_name": "p_mul", - "native_abi": null, - "native_symbol": null, - "native_scope": "expr_mod", - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "p_sub", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null + }, + "visibility": "public" + }, + { + "default_value": "6", + "metadata": { + "fortran_initializer": "b * c" + }, + "name": "p_mul", + "origin": { + "metadata": { "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } - }, - { - "name": "p_div", + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "p_mul", + "native_scope": "expr_mod", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], + "coercions": [], "constraints": [ { - "name": "Constant", - "arguments": [] + "arguments": [], + "name": "Constant" } ], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "dtype": "Int32", "metadata": {}, - "storage": null, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "p_div", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "p_mul", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": "4", "metadata": { "fortran_initializer": "a / c" }, + "name": "p_div", "origin": { - "source_language": "fortran", - "native_name": "p_div", - "native_abi": null, - "native_symbol": null, - "native_scope": "expr_mod", - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } - }, - { - "name": "p_pow", + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "p_div", + "native_scope": "expr_mod", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], + "coercions": [], "constraints": [ { - "name": "Constant", - "arguments": [] + "arguments": [], + "name": "Constant" } ], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "dtype": "Int32", "metadata": {}, - "storage": null, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "p_pow", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "p_div", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": "8", "metadata": { "fortran_initializer": "c ** b" }, + "name": "p_pow", "origin": { - "source_language": "fortran", - "native_name": "p_pow", - "native_abi": null, - "native_symbol": null, - "native_scope": "expr_mod", - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } - }, - { - "name": "p_mix", + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "p_pow", + "native_scope": "expr_mod", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], + "coercions": [], "constraints": [ { - "name": "Constant", - "arguments": [] + "arguments": [], + "name": "Constant" } ], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "dtype": "Int32", "metadata": {}, - "storage": null, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "p_mix", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "p_pow", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": "21", "metadata": { "fortran_initializer": "(a + b) * c - 1" }, + "name": "p_mix", "origin": { - "source_language": "fortran", - "native_name": "p_mix", - "native_abi": null, - "native_symbol": null, - "native_scope": "expr_mod", - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "p_mix", + "native_scope": "expr_mod", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "semantic_type": { + "coercions": [], + "constraints": [ + { + "arguments": [], + "name": "Constant" + } + ], + "dtype": "Int32", + "metadata": {}, + "name": "Int32", + "origin": { + "metadata": { + "allocatable": false, + "constant": true, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "p_mix", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null + }, + "visibility": "public" } - ], - "imports": [], - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "expr_mod", - "native_abi": null, - "native_symbol": null, - "native_scope": "expr_mod", - "source_kind": "module", - "source_type": null, - "source_location": {}, - "metadata": {} - } + ] } ] } 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 20f253b50..674c658a5 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 @@ -1,446 +1,447 @@ { "semantic_modules": [ { - "name": "dims_mod", + "classes": [], + "exported_names": null, "functions": [ { - "name": "use_expr", - "native_name": "use_expr", "arguments": [ { + "default_value": null, + "metadata": {}, "name": "x", + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "0" + ], + "optional": false, + "pointer": false, + "rank": 1, + "shape": [ + "0:n1-1" + ], + "target": false, + "upper_bounds": [ + "n1-1" + ], + "value": false + }, + "native_abi": null, + "native_name": "x", + "native_scope": "use_expr", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, "semantic_type": { - "name": "Int32", - "rank": 1, - "dtype": "Int32", - "shape": [ - "n1" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": true, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { - "rank": 1, - "shape": [ - "n1" - ], + "name": "Int32", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, "lower_bounds": [ "0" ], - "upper_bounds": [ - "n1-1" - ], - "source_shape": [ + "optional": false, + "pointer": false, + "rank": 1, + "shape": [ "0:n1-1" ], - "category": "explicit_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" + "target": false, + "upper_bounds": [ + "n1-1" ], - "contiguous": true, - "allocatable": false, - "pointer": false, - "metadata": {} + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "x", "native_abi": null, - "native_symbol": null, + "native_name": "x", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "integer", + "source_language": "fortran", "source_location": {}, - "metadata": { - "rank": 1, - "shape": [ - "0:n1-1" + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "n1" + ], + "storage": { + "array": { + "allocatable": false, + "axes": [ + "dense" ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, "lower_bounds": [ "0" ], + "metadata": {}, + "order": null, + "pointer": false, + "rank": 1, + "shape": [ + "n1" + ], + "source_shape": [ + "0:n1-1" + ], "upper_bounds": [ "n1-1" - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + ] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "y", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "x", - "native_abi": null, - "native_symbol": null, - "native_scope": "use_expr", - "source_kind": "argument", - "source_type": "integer", - "source_location": {}, "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ - "0:n1-1" - ], - "lower_bounds": [ - "0" + "1:n0*2" ], + "target": false, "upper_bounds": [ - "n1-1" + "n0*2" ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, "value": false - } + }, + "native_abi": null, + "native_name": "y", + "native_scope": "use_expr", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real" }, - "optional": false - }, - { - "name": "y", "semantic_type": { - "name": "Float32", - "rank": 1, - "dtype": "Float32", - "shape": [ - "n0 * 2" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, + "constraints": [], + "dtype": "Float32", "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": true, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { + "name": "Float32", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ - "n0 * 2" + "1:n0*2" ], - "lower_bounds": [], + "target": false, "upper_bounds": [ "n0*2" ], - "source_shape": [ - "1:n0*2" - ], - "category": "explicit_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" - ], - "contiguous": true, - "allocatable": false, - "pointer": false, - "metadata": {} + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "y", "native_abi": null, - "native_symbol": null, + "native_name": "y", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "real", + "source_language": "fortran", "source_location": {}, - "metadata": { - "rank": 1, - "shape": [ - "1:n0*2" - ], - "lower_bounds": [ - "1" - ], - "upper_bounds": [ - "n0*2" - ], + "source_type": "real" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "n0 * 2" + ], + "storage": { + "array": { "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } - }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "y", - "native_abi": null, - "native_symbol": null, - "native_scope": "use_expr", - "source_kind": "argument", - "source_type": "real", - "source_location": {}, - "metadata": { - "rank": 1, - "shape": [ - "1:n0*2" - ], - "lower_bounds": [ - "1" - ], - "upper_bounds": [ - "n0*2" - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false + "axes": [ + "dense" + ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, + "pointer": false, + "rank": 1, + "shape": [ + "n0 * 2" + ], + "source_shape": [ + "1:n0*2" + ], + "upper_bounds": [ + "n0*2" + ] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "optional": false + "visibility": "public" } ], - "return_type": null, - "locals": [], "contracts": [], + "locals": [], + "metadata": {}, + "name": "use_expr", + "native_name": "use_expr", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "use_expr", + "native_scope": "dims_mod", + "native_symbol": null, + "source_kind": "subroutine", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, "projection": [ { - "python_name": "x", + "native_c_identity": null, "native_name": "x", "native_position": 0, + "python_name": "x", "python_position": 0, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" }, { - "python_name": "y", + "native_c_identity": null, "native_name": "y", "native_position": 1, + "python_name": "y", "python_position": 1, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" } ], - "metadata": {}, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "use_expr", - "native_abi": null, - "native_symbol": null, - "native_scope": "dims_mod", - "source_kind": "subroutine", - "source_type": null, - "source_location": {}, - "metadata": {} - } + "return_type": null, + "visibility": "public" } ], - "reexports": [], - "prototypes": [], + "imports": [], + "metadata": {}, + "name": "dims_mod", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "dims_mod", + "native_scope": "dims_mod", + "native_symbol": null, + "source_kind": "module", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, "overload_sets": [], - "classes": [], + "prototypes": [], + "reexports": [], "variables": [ { + "default_value": "4", + "metadata": { + "fortran_initializer": "4" + }, "name": "n0", + "origin": { + "metadata": { + "allocatable": false, + "constant": true, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "n0", + "native_scope": "dims_mod", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], + "coercions": [], "constraints": [ { - "name": "Constant", - "arguments": [] + "arguments": [], + "name": "Constant" } ], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "dtype": "Int32", "metadata": {}, - "storage": null, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "n0", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "n0", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", - "default_value": "4", + "visibility": "public" + }, + { + "default_value": "6", "metadata": { - "fortran_initializer": "4" + "fortran_initializer": "n0 + 2" }, + "name": "n1", "origin": { - "source_language": "fortran", - "native_name": "n0", - "native_abi": null, - "native_symbol": null, - "native_scope": "dims_mod", - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } - }, - { - "name": "n1", + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "n1", + "native_scope": "dims_mod", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], + "coercions": [], "constraints": [ { - "name": "Constant", - "arguments": [] + "arguments": [], + "name": "Constant" } ], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "dtype": "Int32", "metadata": {}, - "storage": null, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "n1", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } - }, - "visibility": "public", - "default_value": "6", - "metadata": { - "fortran_initializer": "n0 + 2" + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "n1", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "origin": { - "source_language": "fortran", - "native_name": "n1", - "native_abi": null, - "native_symbol": null, - "native_scope": "dims_mod", - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false, - "constant": true - } - } + "visibility": "public" } - ], - "imports": [], - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "dims_mod", - "native_abi": null, - "native_symbol": null, - "native_scope": "dims_mod", - "source_kind": "module", - "source_type": null, - "source_location": {}, - "metadata": {} - } + ] } ] } 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 af9bcfa97..057bba4cc 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 @@ -1,367 +1,368 @@ { "semantic_modules": [ { - "name": "particle_mod", - "functions": [ + "classes": [ { - "name": "touch", - "native_name": "touch", - "arguments": [ + "base_classes": [], + "contracts": [], + "destructors": [], + "fields": [ { - "name": "p", - "semantic_type": { - "name": "particle", - "rank": 0, - "dtype": "particle", - "shape": [], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, - "metadata": {}, - "storage": { - "kind": "reference", - "read_only": false, - "mutable": true, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "p", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "type(particle)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } - }, - "visibility": "public", "default_value": null, "metadata": {}, + "name": "id", "origin": { - "source_language": "fortran", - "native_name": "p", - "native_abi": null, - "native_symbol": null, - "native_scope": "touch", - "source_kind": "argument", - "source_type": "type(particle)", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } + }, + "native_abi": null, + "native_name": "id", + "native_scope": null, + "native_symbol": null, + "source_kind": "field", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" }, - "optional": false - } - ], - "return_type": null, - "locals": [], - "contracts": [], - "projection": [ - { - "python_name": "p", - "native_name": "p", - "native_position": 0, - "python_position": 0, - "result_position": null, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null - } - ], - "metadata": {}, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "touch", - "native_abi": null, - "native_symbol": null, - "native_scope": "particle_mod", - "source_kind": "subroutine", - "source_type": null, - "source_location": {}, - "metadata": {} - } - } - ], - "reexports": [], - "prototypes": [], - "overload_sets": [], - "classes": [ - { - "name": "particle", - "native_name": "particle", - "fields": [ - { - "name": "id", "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": null, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "id", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } + }, + "native_abi": null, + "native_name": "id", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "x", "origin": { - "source_language": "fortran", - "native_name": "id", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "field", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [ + "1" + ], "optional": false, + "pointer": false, + "rank": 1, + "shape": [ + "3" + ], + "target": false, + "upper_bounds": [ + "3" + ], "value": false - } - } - }, - { - "name": "x", + }, + "native_abi": null, + "native_name": "x", + "native_scope": null, + "native_symbol": null, + "source_kind": "field", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, "semantic_type": { - "name": "Float64", - "rank": 1, - "dtype": "Float64", - "shape": [ - "3" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Float64", "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": false, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { + "name": "Float64", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ "3" ], - "lower_bounds": [], - "upper_bounds": [], - "source_shape": [ + "target": false, + "upper_bounds": [ "3" ], - "category": "explicit_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" - ], - "contiguous": true, - "allocatable": false, - "pointer": false, - "metadata": {} + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "x", "native_abi": null, - "native_symbol": null, + "native_name": "x", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "real(kind=8)", + "source_language": "fortran", "source_location": {}, - "metadata": { + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "3" + ], + "storage": { + "array": { + "allocatable": false, + "axes": [ + "dense" + ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, + "pointer": false, "rank": 1, "shape": [ "3" ], - "lower_bounds": [ - "1" - ], - "upper_bounds": [ + "source_shape": [ "3" ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + "upper_bounds": [] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "x", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "field", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 1, - "shape": [ - "3" - ], - "lower_bounds": [ - "1" - ], - "upper_bounds": [ - "3" - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } + "visibility": "public" } ], - "methods": [], - "destructors": [], - "overload_sets": [], - "base_classes": [], - "contracts": [], "metadata": { - "fortran_type_attributes": [], - "fortran_component_order": [ - "id", - "x" - ], "fortran_component_facts": [ { - "name": "id", - "source_type": "integer", + "allocatable": false, "kind": "", + "name": "id", + "pointer": false, "rank": 0, "shape": [], - "allocatable": false, - "pointer": false, + "source_type": "integer", "target": false }, { - "name": "x", - "source_type": "real(kind=8)", + "allocatable": false, "kind": "8", + "name": "x", + "pointer": false, "rank": 1, "shape": [ "3" ], - "allocatable": false, - "pointer": false, + "source_type": "real(kind=8)", "target": false } ], + "fortran_component_order": [ + "id", + "x" + ], + "fortran_direct_layout": false, "fortran_layout_policy": "accessors", - "fortran_direct_layout": false + "fortran_type_attributes": [] }, - "visibility": "public", + "methods": [], + "name": "particle", + "native_name": "particle", "origin": { - "source_language": "fortran", - "native_name": "particle", + "metadata": {}, "native_abi": null, - "native_symbol": null, + "native_name": "particle", "native_scope": "particle_mod", + "native_symbol": null, "source_kind": "derived_type", - "source_type": null, + "source_language": "fortran", "source_location": {}, - "metadata": {} - } + "source_type": null + }, + "overload_sets": [], + "visibility": "public" + } + ], + "exported_names": null, + "functions": [ + { + "arguments": [ + { + "default_value": null, + "metadata": {}, + "name": "p", + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "p", + "native_scope": "touch", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "type(particle)" + }, + "semantic_type": { + "coercions": [], + "constraints": [], + "dtype": "particle", + "metadata": {}, + "name": "particle", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "p", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "type(particle)" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": false + } + }, + "visibility": "public" + } + ], + "contracts": [], + "locals": [], + "metadata": {}, + "name": "touch", + "native_name": "touch", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "touch", + "native_scope": "particle_mod", + "native_symbol": null, + "source_kind": "subroutine", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, + "projection": [ + { + "native_c_identity": null, + "native_name": "p", + "native_position": 0, + "python_name": "p", + "python_position": 0, + "result_position": null, + "value": null, + "value_cast": null, + "value_kind": "" + } + ], + "return_type": null, + "visibility": "public" } ], - "variables": [], "imports": [], "metadata": {}, + "name": "particle_mod", "origin": { - "source_language": "fortran", - "native_name": "particle_mod", + "metadata": {}, "native_abi": null, - "native_symbol": null, + "native_name": "particle_mod", "native_scope": "particle_mod", + "native_symbol": null, "source_kind": "module", - "source_type": null, + "source_language": "fortran", "source_location": {}, - "metadata": {} - } + "source_type": null + }, + "overload_sets": [], + "prototypes": [], + "reexports": [], + "variables": [] } ] } 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 27fab4266..7c00c8bc9 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 @@ -1,485 +1,486 @@ { "semantic_modules": [ { - "name": "mesh_mod", - "functions": [], - "reexports": [], - "prototypes": [], - "overload_sets": [], "classes": [ { - "name": "node", - "native_name": "node", + "base_classes": [], + "contracts": [], + "destructors": [], "fields": [ { + "default_value": null, + "metadata": {}, "name": "id", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "id", + "native_scope": null, + "native_symbol": null, + "source_kind": "field", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": null, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "id", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } + }, + "native_abi": null, + "native_name": "id", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "xyz", "origin": { - "source_language": "fortran", - "native_name": "id", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "field", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [ + "1" + ], "optional": false, + "pointer": false, + "rank": 1, + "shape": [ + "3" + ], + "target": false, + "upper_bounds": [ + "3" + ], "value": false - } - } - }, - { - "name": "xyz", + }, + "native_abi": null, + "native_name": "xyz", + "native_scope": null, + "native_symbol": null, + "source_kind": "field", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, "semantic_type": { - "name": "Float64", - "rank": 1, - "dtype": "Float64", - "shape": [ - "3" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Float64", "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": false, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { + "name": "Float64", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ "3" ], - "lower_bounds": [], - "upper_bounds": [], - "source_shape": [ + "target": false, + "upper_bounds": [ "3" ], - "category": "explicit_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" - ], - "contiguous": true, - "allocatable": false, - "pointer": false, - "metadata": {} + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "xyz", "native_abi": null, - "native_symbol": null, + "native_name": "xyz", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "real(kind=8)", + "source_language": "fortran", "source_location": {}, - "metadata": { + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "3" + ], + "storage": { + "array": { + "allocatable": false, + "axes": [ + "dense" + ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, + "pointer": false, "rank": 1, "shape": [ "3" ], - "lower_bounds": [ - "1" - ], - "upper_bounds": [ + "source_shape": [ "3" ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + "upper_bounds": [] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "xyz", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "field", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 1, - "shape": [ - "3" - ], - "lower_bounds": [ - "1" - ], - "upper_bounds": [ - "3" - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } + "visibility": "public" } ], - "methods": [], - "destructors": [], - "overload_sets": [], - "base_classes": [], - "contracts": [], "metadata": { - "fortran_type_attributes": [], - "fortran_component_order": [ - "id", - "xyz" - ], "fortran_component_facts": [ { - "name": "id", - "source_type": "integer", + "allocatable": false, "kind": "", + "name": "id", + "pointer": false, "rank": 0, "shape": [], - "allocatable": false, - "pointer": false, + "source_type": "integer", "target": false }, { - "name": "xyz", - "source_type": "real(kind=8)", + "allocatable": false, "kind": "8", + "name": "xyz", + "pointer": false, "rank": 1, "shape": [ "3" ], - "allocatable": false, - "pointer": false, + "source_type": "real(kind=8)", "target": false } ], + "fortran_component_order": [ + "id", + "xyz" + ], + "fortran_direct_layout": false, "fortran_layout_policy": "accessors", - "fortran_direct_layout": false + "fortran_type_attributes": [] }, - "visibility": "public", + "methods": [], + "name": "node", + "native_name": "node", "origin": { - "source_language": "fortran", - "native_name": "node", + "metadata": {}, "native_abi": null, - "native_symbol": null, + "native_name": "node", "native_scope": "mesh_mod", + "native_symbol": null, "source_kind": "derived_type", - "source_type": null, + "source_language": "fortran", "source_location": {}, - "metadata": {} - } + "source_type": null + }, + "overload_sets": [], + "visibility": "public" }, { - "name": "mesh", - "native_name": "mesh", + "base_classes": [], + "contracts": [], + "destructors": [], "fields": [ { + "default_value": null, + "metadata": {}, "name": "nnodes", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "nnodes", + "native_scope": null, + "native_symbol": null, + "source_kind": "field", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": null, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "nnodes", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } + }, + "native_abi": null, + "native_name": "nnodes", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "nodes", "origin": { - "source_language": "fortran", - "native_name": "nnodes", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "field", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, + "allocatable": true, "contiguous": false, + "lower_bounds": [ + null + ], "optional": false, + "pointer": false, + "rank": 1, + "shape": [ + ":" + ], + "target": false, + "upper_bounds": [ + null + ], "value": false - } - } - }, - { - "name": "nodes", + }, + "native_abi": null, + "native_name": "nodes", + "native_scope": null, + "native_symbol": null, + "source_kind": "field", + "source_language": "fortran", + "source_location": {}, + "source_type": "type(node)" + }, "semantic_type": { - "name": "node", - "rank": 1, - "dtype": "node", - "shape": [ - ":" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "node", "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": false, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { + "name": "node", + "origin": { + "metadata": { + "allocatable": true, + "contiguous": false, + "lower_bounds": [ + null + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ ":" ], - "lower_bounds": [], - "upper_bounds": [], - "source_shape": [ - ":" - ], - "category": "deferred_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" + "target": false, + "upper_bounds": [ + null ], - "contiguous": true, - "allocatable": true, - "pointer": false, - "metadata": {} + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "nodes", "native_abi": null, - "native_symbol": null, + "native_name": "nodes", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "type(node)", + "source_language": "fortran", "source_location": {}, - "metadata": { + "source_type": "type(node)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + ":" + ], + "storage": { + "array": { + "allocatable": true, + "axes": [ + "dense" + ], + "category": "deferred_shape", + "contiguous": true, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, + "pointer": false, "rank": 1, "shape": [ ":" ], - "lower_bounds": [ - null - ], - "upper_bounds": [ - null + "source_shape": [ + ":" ], - "allocatable": true, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + "upper_bounds": [] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "nodes", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "field", - "source_type": "type(node)", - "source_location": {}, - "metadata": { - "rank": 1, - "shape": [ - ":" - ], - "lower_bounds": [ - null - ], - "upper_bounds": [ - null - ], - "allocatable": true, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } + "visibility": "public" } ], - "methods": [], - "destructors": [], - "overload_sets": [], - "base_classes": [], - "contracts": [], "metadata": { - "fortran_type_attributes": [], - "fortran_component_order": [ - "nnodes", - "nodes" - ], "fortran_component_facts": [ { - "name": "nnodes", - "source_type": "integer", + "allocatable": false, "kind": "", + "name": "nnodes", + "pointer": false, "rank": 0, "shape": [], - "allocatable": false, - "pointer": false, + "source_type": "integer", "target": false }, { - "name": "nodes", - "source_type": "type(node)", + "allocatable": true, "kind": "node", + "name": "nodes", + "pointer": false, "rank": 1, "shape": [ ":" ], - "allocatable": true, - "pointer": false, + "source_type": "type(node)", "target": false } ], + "fortran_component_order": [ + "nnodes", + "nodes" + ], + "fortran_direct_layout": false, "fortran_layout_policy": "accessors", - "fortran_direct_layout": false + "fortran_type_attributes": [] }, - "visibility": "public", + "methods": [], + "name": "mesh", + "native_name": "mesh", "origin": { - "source_language": "fortran", - "native_name": "mesh", + "metadata": {}, "native_abi": null, - "native_symbol": null, + "native_name": "mesh", "native_scope": "mesh_mod", + "native_symbol": null, "source_kind": "derived_type", - "source_type": null, + "source_language": "fortran", "source_location": {}, - "metadata": {} - } + "source_type": null + }, + "overload_sets": [], + "visibility": "public" } ], - "variables": [], + "exported_names": null, + "functions": [], "imports": [], "metadata": {}, + "name": "mesh_mod", "origin": { - "source_language": "fortran", - "native_name": "mesh_mod", + "metadata": {}, "native_abi": null, - "native_symbol": null, + "native_name": "mesh_mod", "native_scope": "mesh_mod", + "native_symbol": null, "source_kind": "module", - "source_type": null, + "source_language": "fortran", "source_location": {}, - "metadata": {} - } + "source_type": null + }, + "overload_sets": [], + "prototypes": [], + "reexports": [], + "variables": [] } ] } 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 876c6f9f9..0520ccd5e 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 @@ -1,2573 +1,2574 @@ { "semantic_modules": [ { - "name": "modern_math_physics", - "functions": [ + "classes": [ { - "name": "init_particle", - "native_name": "init_particle", - "arguments": [ + "base_classes": [], + "contracts": [], + "destructors": [], + "fields": [ { - "name": "p", - "semantic_type": { - "name": "particle", - "rank": 0, - "dtype": "particle", - "shape": [], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, - "metadata": {}, - "storage": { - "kind": "reference", - "read_only": false, - "mutable": true, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "p", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "type(particle)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } - }, - "visibility": "public", "default_value": null, "metadata": {}, + "name": "id", "origin": { - "source_language": "fortran", - "native_name": "p", - "native_abi": null, - "native_symbol": null, - "native_scope": "init_particle", - "source_kind": "argument", - "source_type": "type(particle)", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } + }, + "native_abi": null, + "native_name": "id", + "native_scope": null, + "native_symbol": null, + "source_kind": "field", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" }, - "optional": false - }, - { - "name": "pid", "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": { - "kind": "reference", - "read_only": true, - "mutable": false, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "pid", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } + }, + "native_abi": null, + "native_name": "id", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "mass", "origin": { - "source_language": "fortran", - "native_name": "pid", - "native_abi": null, - "native_symbol": null, - "native_scope": "init_particle", - "source_kind": "argument", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } + }, + "native_abi": null, + "native_name": "mass", + "native_scope": null, + "native_symbol": null, + "source_kind": "field", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" }, - "optional": false - }, - { - "name": "mass", "semantic_type": { - "name": "Float64", - "rank": 0, - "dtype": "Float64", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Float64", "metadata": {}, - "storage": { - "kind": "reference", - "read_only": true, - "mutable": false, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, + "name": "Float64", "origin": { - "source_language": "fortran", - "native_name": "mass", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "real(kind=8)", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } + }, + "native_abi": null, + "native_name": "mass", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "position", "origin": { - "source_language": "fortran", - "native_name": "mass", - "native_abi": null, - "native_symbol": null, - "native_scope": "init_particle", - "source_kind": "argument", - "source_type": "real(kind=8)", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [ + "1" + ], "optional": false, + "pointer": false, + "rank": 1, + "shape": [ + "3" + ], + "target": false, + "upper_bounds": [ + "3" + ], "value": false - } + }, + "native_abi": null, + "native_name": "position", + "native_scope": null, + "native_symbol": null, + "source_kind": "field", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" }, - "optional": false - }, - { - "name": "x", "semantic_type": { - "name": "Float64", - "rank": 0, - "dtype": "Float64", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Float64", "metadata": {}, - "storage": { - "kind": "reference", - "read_only": true, - "mutable": false, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, + "name": "Float64", "origin": { - "source_language": "fortran", - "native_name": "x", + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, + "rank": 1, + "shape": [ + "3" + ], + "target": false, + "upper_bounds": [ + "3" + ], + "value": false + }, "native_abi": null, - "native_symbol": null, + "native_name": "position", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "real(kind=8)", + "source_language": "fortran", "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "3" + ], + "storage": { + "array": { "allocatable": false, + "axes": [ + "dense" + ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + "rank": 1, + "shape": [ + "3" + ], + "source_shape": [ + "3" + ], + "upper_bounds": [] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "visibility": "public", + "visibility": "public" + } + ], + "metadata": { + "fortran_component_facts": [ + { + "allocatable": false, + "kind": "", + "name": "id", + "pointer": false, + "rank": 0, + "shape": [], + "source_type": "integer", + "target": false + }, + { + "allocatable": false, + "kind": "8", + "name": "mass", + "pointer": false, + "rank": 0, + "shape": [], + "source_type": "real(kind=8)", + "target": false + }, + { + "allocatable": false, + "kind": "8", + "name": "position", + "pointer": false, + "rank": 1, + "shape": [ + "3" + ], + "source_type": "real(kind=8)", + "target": false + } + ], + "fortran_component_order": [ + "id", + "mass", + "position" + ], + "fortran_direct_layout": false, + "fortran_layout_policy": "accessors", + "fortran_type_attributes": [] + }, + "methods": [], + "name": "particle", + "native_name": "particle", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "particle", + "native_scope": "modern_math_physics", + "native_symbol": null, + "source_kind": "derived_type", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, + "overload_sets": [], + "visibility": "public" + }, + { + "base_classes": [], + "contracts": [], + "destructors": [], + "fields": [ + { "default_value": null, "metadata": {}, + "name": "values", "origin": { - "source_language": "fortran", - "native_name": "x", - "native_abi": null, - "native_symbol": null, - "native_scope": "init_particle", - "source_kind": "argument", - "source_type": "real(kind=8)", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [ + "1" + ], "optional": false, + "pointer": false, + "rank": 1, + "shape": [ + "3" + ], + "target": false, + "upper_bounds": [ + "3" + ], "value": false - } + }, + "native_abi": null, + "native_name": "values", + "native_scope": null, + "native_symbol": null, + "source_kind": "field", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" }, - "optional": false - }, - { - "name": "y", "semantic_type": { - "name": "Float64", - "rank": 0, - "dtype": "Float64", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Float64", "metadata": {}, - "storage": { - "kind": "reference", - "read_only": true, - "mutable": false, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, + "name": "Float64", "origin": { - "source_language": "fortran", - "native_name": "y", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "real(kind=8)", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [ + "1" + ], "optional": false, + "pointer": false, + "rank": 1, + "shape": [ + "3" + ], + "target": false, + "upper_bounds": [ + "3" + ], "value": false - } - } - }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "y", - "native_abi": null, - "native_symbol": null, - "native_scope": "init_particle", - "source_kind": "argument", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - }, - "optional": false - }, - { - "name": "z", - "semantic_type": { - "name": "Float64", - "rank": 0, - "dtype": "Float64", - "shape": [], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, - "metadata": {}, - "storage": { - "kind": "reference", - "read_only": true, - "mutable": false, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "z", + }, "native_abi": null, - "native_symbol": null, + "native_name": "values", "native_scope": null, - "source_kind": "variable", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } - }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "z", - "native_abi": null, - "native_symbol": null, - "native_scope": "init_particle", - "source_kind": "argument", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - }, - "optional": false - } - ], - "return_type": null, - "locals": [], - "contracts": [], - "projection": [ - { - "python_name": "p", - "native_name": "p", - "native_position": 0, - "python_position": 0, - "result_position": null, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null - }, - { - "python_name": "pid", - "native_name": "pid", - "native_position": 1, - "python_position": 1, - "result_position": null, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null - }, - { - "python_name": "mass", - "native_name": "mass", - "native_position": 2, - "python_position": 2, - "result_position": null, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null - }, - { - "python_name": "x", - "native_name": "x", - "native_position": 3, - "python_position": 3, - "result_position": null, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null - }, - { - "python_name": "y", - "native_name": "y", - "native_position": 4, - "python_position": 4, - "result_position": null, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null - }, - { - "python_name": "z", - "native_name": "z", - "native_position": 5, - "python_position": 5, - "result_position": null, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null - } - ], - "metadata": {}, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "init_particle", - "native_abi": null, - "native_symbol": null, - "native_scope": "modern_math_physics", - "source_kind": "subroutine", - "source_type": null, - "source_location": {}, - "metadata": {} - } - }, - { - "name": "kinetic_energy", - "native_name": "kinetic_energy", - "arguments": [ - { - "name": "p", - "semantic_type": { - "name": "particle", - "rank": 0, - "dtype": "particle", - "shape": [], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, - "metadata": {}, - "storage": { - "kind": "reference", - "read_only": true, - "mutable": false, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "p", - "native_abi": null, "native_symbol": null, - "native_scope": null, "source_kind": "variable", - "source_type": "type(particle)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } - }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "p", - "native_abi": null, - "native_symbol": null, - "native_scope": "kinetic_energy", - "source_kind": "argument", - "source_type": "type(particle)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - }, - "optional": false - }, - { - "name": "vx", - "semantic_type": { - "name": "Float64", - "rank": 0, - "dtype": "Float64", - "shape": [], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, - "metadata": {}, - "storage": { - "kind": "reference", - "read_only": true, - "mutable": false, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, - "origin": { "source_language": "fortran", - "native_name": "vx", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "real(kind=8)", "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } - }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "vx", - "native_abi": null, - "native_symbol": null, - "native_scope": "kinetic_energy", - "source_kind": "argument", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - }, - "optional": false - }, - { - "name": "vy", - "semantic_type": { - "name": "Float64", - "rank": 0, - "dtype": "Float64", - "shape": [], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true + "source_type": "real(kind=8)" }, - "metadata": {}, - "storage": { - "kind": "reference", - "read_only": true, - "mutable": false, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "vy", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } - }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "vy", - "native_abi": null, - "native_symbol": null, - "native_scope": "kinetic_energy", - "source_kind": "argument", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - }, - "optional": false - }, - { - "name": "vz", - "semantic_type": { - "name": "Float64", - "rank": 0, - "dtype": "Float64", - "shape": [], - "constraints": [], - "coercions": [], "ownership": { - "ownership": "borrowed", + "aliasing": true, "mutable": false, - "aliasing": true + "ownership": "borrowed" }, - "metadata": {}, + "rank": 1, + "shape": [ + "3" + ], "storage": { - "kind": "reference", - "read_only": true, - "mutable": false, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "vz", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], + "array": { "allocatable": false, + "axes": [ + "dense" + ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } - }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "vz", - "native_abi": null, - "native_symbol": null, - "native_scope": "kinetic_energy", - "source_kind": "argument", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false + "rank": 1, + "shape": [ + "3" + ], + "source_shape": [ + "3" + ], + "upper_bounds": [] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "optional": false + "visibility": "public" } ], - "return_type": { - "name": "Float64", - "rank": 0, - "dtype": "Float64", - "shape": [], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, - "metadata": {}, - "storage": null, - "origin": { - "source_language": "fortran", - "native_name": "e", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], + "metadata": { + "fortran_component_facts": [ + { "allocatable": false, + "kind": "8", + "name": "values", "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false + "rank": 1, + "shape": [ + "3" + ], + "source_type": "real(kind=8)", + "target": false } - } + ], + "fortran_component_order": [ + "values" + ], + "fortran_direct_layout": false, + "fortran_layout_policy": "accessors", + "fortran_type_attributes": [] }, - "locals": [], - "contracts": [], - "projection": [ - { - "python_name": "p", - "native_name": "p", - "native_position": 0, - "python_position": 0, - "result_position": null, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null - }, - { - "python_name": "vx", - "native_name": "vx", - "native_position": 1, - "python_position": 1, - "result_position": null, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null - }, - { - "python_name": "vy", - "native_name": "vy", - "native_position": 2, - "python_position": 2, - "result_position": null, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null - }, - { - "python_name": "vz", - "native_name": "vz", - "native_position": 3, - "python_position": 3, - "result_position": null, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null - } - ], - "metadata": {}, - "visibility": "public", + "methods": [], + "name": "vector3", + "native_name": "vector3", "origin": { - "source_language": "fortran", - "native_name": "kinetic_energy", + "metadata": {}, "native_abi": null, - "native_symbol": null, + "native_name": "vector3", "native_scope": "modern_math_physics", - "source_kind": "function", - "source_type": null, + "native_symbol": null, + "source_kind": "derived_type", + "source_language": "fortran", "source_location": {}, - "metadata": {} - } + "source_type": null + }, + "overload_sets": [], + "visibility": "public" }, { - "name": "scale_vector", - "native_name": "scale_vector", - "arguments": [ + "base_classes": [], + "contracts": [], + "destructors": [], + "fields": [ { - "name": "v", - "semantic_type": { - "name": "Float64", - "rank": 1, - "dtype": "Float64", - "shape": [ - "::" - ], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, - "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": true, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { - "rank": 1, - "shape": [ - "::" - ], - "lower_bounds": [], - "upper_bounds": [], - "source_shape": [ - ":" - ], - "category": "assumed_shape", - "order": null, - "copy_order": null, - "axes": [ - "strided" - ], - "contiguous": false, - "allocatable": false, - "pointer": false, - "metadata": {} - }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "v", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 1, - "shape": [ - ":" - ], - "lower_bounds": [ - null - ], - "upper_bounds": [ - null - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } - }, - "visibility": "public", "default_value": null, "metadata": {}, + "name": "code", "origin": { - "source_language": "fortran", - "native_name": "v", - "native_abi": null, - "native_symbol": null, - "native_scope": "scale_vector", - "source_kind": "argument", - "source_type": "real(kind=8)", - "source_location": {}, "metadata": { - "rank": 1, - "shape": [ - ":" - ], - "lower_bounds": [ - null - ], - "upper_bounds": [ - null - ], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } + }, + "native_abi": null, + "native_name": "code", + "native_scope": null, + "native_symbol": null, + "source_kind": "field", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" }, - "optional": false - }, - { - "name": "alpha", "semantic_type": { - "name": "Float64", - "rank": 0, - "dtype": "Float64", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": { - "kind": "reference", - "read_only": true, - "mutable": false, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "alpha", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "real(kind=8)", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } - }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "alpha", - "native_abi": null, - "native_symbol": null, - "native_scope": "scale_vector", - "source_kind": "argument", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + }, + "native_abi": null, + "native_name": "code", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "optional": false - } - ], - "return_type": null, - "locals": [], - "contracts": [], - "projection": [ - { - "python_name": "v", - "native_name": "v", - "native_position": 0, - "python_position": 0, - "result_position": null, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null - }, - { - "python_name": "alpha", - "native_name": "alpha", - "native_position": 1, - "python_position": 1, - "result_position": null, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null + "visibility": "public" } ], - "metadata": {}, - "visibility": "public", + "metadata": { + "fortran_component_facts": [ + { + "allocatable": false, + "kind": "", + "name": "code", + "pointer": false, + "rank": 0, + "shape": [], + "source_type": "integer", + "target": false + } + ], + "fortran_component_order": [ + "code" + ], + "fortran_direct_layout": false, + "fortran_layout_policy": "accessors", + "fortran_type_attributes": [] + }, + "methods": [], + "name": "hidden_state", + "native_name": "hidden_state", "origin": { - "source_language": "fortran", - "native_name": "scale_vector", + "metadata": {}, "native_abi": null, - "native_symbol": null, + "native_name": "hidden_state", "native_scope": "modern_math_physics", - "source_kind": "subroutine", - "source_type": null, + "native_symbol": null, + "source_kind": "derived_type", + "source_language": "fortran", "source_location": {}, - "metadata": {} - } - }, + "source_type": null + }, + "overload_sets": [], + "visibility": "private" + } + ], + "exported_names": null, + "functions": [ { - "name": "dot3", - "native_name": "dot3", "arguments": [ { - "name": "a", + "default_value": null, + "metadata": {}, + "name": "p", + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "p", + "native_scope": "init_particle", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "type(particle)" + }, "semantic_type": { - "name": "Float64", - "rank": 1, - "dtype": "Float64", - "shape": [ - "3" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "particle", "metadata": {}, - "storage": { - "kind": "array", - "read_only": true, - "mutable": false, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { - "rank": 1, - "shape": [ - "3" - ], - "lower_bounds": [], - "upper_bounds": [], - "source_shape": [ - "3" - ], - "category": "explicit_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" - ], - "contiguous": true, + "name": "particle", + "origin": { + "metadata": { "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, "pointer": false, - "metadata": {} + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "a", "native_abi": null, - "native_symbol": null, + "native_name": "p", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "real(kind=8)", + "source_language": "fortran", "source_location": {}, - "metadata": { - "rank": 1, - "shape": [ - "3" - ], - "lower_bounds": [ - "1" - ], - "upper_bounds": [ - "3" - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + "source_type": "type(particle)" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": false } }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "pid", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "a", - "native_abi": null, - "native_symbol": null, - "native_scope": "dot3", - "source_kind": "argument", - "source_type": "real(kind=8)", - "source_location": {}, "metadata": { - "rank": 1, - "shape": [ - "3" - ], - "lower_bounds": [ - "1" - ], - "upper_bounds": [ - "3" - ], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } + }, + "native_abi": null, + "native_name": "pid", + "native_scope": "init_particle", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" }, - "optional": false - }, - { - "name": "b", "semantic_type": { - "name": "Float64", - "rank": 1, - "dtype": "Float64", - "shape": [ - "3" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": { - "kind": "array", - "read_only": true, - "mutable": false, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { - "rank": 1, - "shape": [ - "3" - ], - "lower_bounds": [], - "upper_bounds": [], - "source_shape": [ - "3" - ], - "category": "explicit_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" - ], - "contiguous": true, + "name": "Int32", + "origin": { + "metadata": { "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, "pointer": false, - "metadata": {} + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "b", "native_abi": null, - "native_symbol": null, + "native_name": "pid", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "real(kind=8)", + "source_language": "fortran", "source_location": {}, - "metadata": { - "rank": 1, - "shape": [ - "3" - ], - "lower_bounds": [ - "1" - ], - "upper_bounds": [ - "3" - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": true } }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "mass", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "b", - "native_abi": null, - "native_symbol": null, - "native_scope": "dot3", - "source_kind": "argument", - "source_type": "real(kind=8)", - "source_location": {}, "metadata": { - "rank": 1, - "shape": [ - "3" - ], - "lower_bounds": [ - "1" - ], - "upper_bounds": [ - "3" - ], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } + }, + "native_abi": null, + "native_name": "mass", + "native_scope": "init_particle", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" }, - "optional": false - } - ], - "return_type": { - "name": "Float64", - "rank": 0, - "dtype": "Float64", - "shape": [], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, - "metadata": {}, - "storage": null, - "origin": { - "source_language": "fortran", - "native_name": "s", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } - }, - "locals": [], - "contracts": [], - "projection": [ - { - "python_name": "a", - "native_name": "a", - "native_position": 0, - "python_position": 0, - "result_position": null, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null - }, - { - "python_name": "b", - "native_name": "b", - "native_position": 1, - "python_position": 1, - "result_position": null, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null - } - ], - "metadata": {}, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "dot3", - "native_abi": null, - "native_symbol": null, - "native_scope": "modern_math_physics", - "source_kind": "function", - "source_type": null, - "source_location": {}, - "metadata": {} - } - }, - { - "name": "fill_identity3", - "native_name": "fill_identity3", - "arguments": [ - { - "name": "a", "semantic_type": { - "name": "Float64", - "rank": 2, - "dtype": "Float64", - "shape": [ - "3", - "3" - ], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, + "coercions": [], + "constraints": [], + "dtype": "Float64", "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": true, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { - "rank": 2, - "shape": [ - "3", - "3" - ], - "lower_bounds": [], - "upper_bounds": [], - "source_shape": [ - "3", - "3" - ], - "category": "explicit_shape", - "order": "ORDER_F", - "copy_order": null, - "axes": [ - "dense", - "dense" - ], - "contiguous": true, + "name": "Float64", + "origin": { + "metadata": { "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, "pointer": false, - "metadata": {} + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "a", "native_abi": null, - "native_symbol": null, + "native_name": "mass", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "real(kind=8)", + "source_language": "fortran", "source_location": {}, + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": true + } + }, + "visibility": "public" + }, + { + "default_value": null, + "metadata": {}, + "name": "x", + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "x", + "native_scope": "init_particle", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, + "semantic_type": { + "coercions": [], + "constraints": [], + "dtype": "Float64", + "metadata": {}, + "name": "Float64", + "origin": { "metadata": { - "rank": 2, - "shape": [ - "3", - "3" - ], - "lower_bounds": [ - "1", - "1" - ], - "upper_bounds": [ - "3", - "3" - ], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } + }, + "native_abi": null, + "native_name": "x", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": true } }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "y", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "a", + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, "native_abi": null, + "native_name": "y", + "native_scope": "init_particle", "native_symbol": null, - "native_scope": "fill_identity3", "source_kind": "argument", - "source_type": "real(kind=8)", + "source_language": "fortran", "source_location": {}, + "source_type": "real(kind=8)" + }, + "semantic_type": { + "coercions": [], + "constraints": [], + "dtype": "Float64", + "metadata": {}, + "name": "Float64", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "y", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": true + } + }, + "visibility": "public" + }, + { + "default_value": null, + "metadata": {}, + "name": "z", + "optional": false, + "origin": { "metadata": { - "rank": 2, - "shape": [ - "3", - "3" - ], - "lower_bounds": [ - "1", - "1" - ], - "upper_bounds": [ - "3", - "3" - ], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false + }, + "native_abi": null, + "native_name": "z", + "native_scope": "init_particle", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, + "semantic_type": { + "coercions": [], + "constraints": [], + "dtype": "Float64", + "metadata": {}, + "name": "Float64", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "z", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": true } }, - "optional": false + "visibility": "public" } ], - "return_type": null, - "locals": [], "contracts": [], + "locals": [], + "metadata": {}, + "name": "init_particle", + "native_name": "init_particle", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "init_particle", + "native_scope": "modern_math_physics", + "native_symbol": null, + "source_kind": "subroutine", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, "projection": [ { - "python_name": "a", - "native_name": "a", + "native_c_identity": null, + "native_name": "p", "native_position": 0, + "python_name": "p", "python_position": 0, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" + }, + { + "native_c_identity": null, + "native_name": "pid", + "native_position": 1, + "python_name": "pid", + "python_position": 1, + "result_position": null, + "value": null, + "value_cast": null, + "value_kind": "" + }, + { + "native_c_identity": null, + "native_name": "mass", + "native_position": 2, + "python_name": "mass", + "python_position": 2, + "result_position": null, + "value": null, + "value_cast": null, + "value_kind": "" + }, + { + "native_c_identity": null, + "native_name": "x", + "native_position": 3, + "python_name": "x", + "python_position": 3, + "result_position": null, + "value": null, + "value_cast": null, + "value_kind": "" + }, + { + "native_c_identity": null, + "native_name": "y", + "native_position": 4, + "python_name": "y", + "python_position": 4, + "result_position": null, + "value": null, + "value_cast": null, + "value_kind": "" + }, + { + "native_c_identity": null, + "native_name": "z", + "native_position": 5, + "python_name": "z", + "python_position": 5, + "result_position": null, + "value": null, + "value_cast": null, + "value_kind": "" } ], - "metadata": {}, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "fill_identity3", - "native_abi": null, - "native_symbol": null, - "native_scope": "modern_math_physics", - "source_kind": "subroutine", - "source_type": null, - "source_location": {}, - "metadata": {} - } + "return_type": null, + "visibility": "public" }, { - "name": "normalize_particle", - "native_name": "normalize_particle", "arguments": [ { + "default_value": null, + "metadata": {}, "name": "p", + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "p", + "native_scope": "kinetic_energy", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "type(particle)" + }, "semantic_type": { + "coercions": [], + "constraints": [], + "dtype": "particle", + "metadata": {}, "name": "particle", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "p", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "type(particle)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, "rank": 0, - "dtype": "particle", "shape": [], - "constraints": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": true + } + }, + "visibility": "public" + }, + { + "default_value": null, + "metadata": {}, + "name": "vx", + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "vx", + "native_scope": "kinetic_energy", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, + "semantic_type": { "coercions": [], + "constraints": [], + "dtype": "Float64", + "metadata": {}, + "name": "Float64", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "vx", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": false, "ownership": "borrowed", - "mutable": true, - "aliasing": true + "pointer_depth": 1, + "read_only": true + } + }, + "visibility": "public" + }, + { + "default_value": null, + "metadata": {}, + "name": "vy", + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false }, + "native_abi": null, + "native_name": "vy", + "native_scope": "kinetic_energy", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, + "semantic_type": { + "coercions": [], + "constraints": [], + "dtype": "Float64", "metadata": {}, - "storage": { - "kind": "reference", - "read_only": false, - "mutable": true, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, + "name": "Float64", "origin": { - "source_language": "fortran", - "native_name": "p", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "type(particle)", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } + }, + "native_abi": null, + "native_name": "vy", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": true } }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "vz", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "p", - "native_abi": null, - "native_symbol": null, - "native_scope": "normalize_particle", - "source_kind": "argument", - "source_type": "type(particle)", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false + }, + "native_abi": null, + "native_name": "vz", + "native_scope": "kinetic_energy", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, + "semantic_type": { + "coercions": [], + "constraints": [], + "dtype": "Float64", + "metadata": {}, + "name": "Float64", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "vz", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": true } }, - "optional": false + "visibility": "public" } ], - "return_type": null, - "locals": [], "contracts": [], + "locals": [], + "metadata": {}, + "name": "kinetic_energy", + "native_name": "kinetic_energy", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "kinetic_energy", + "native_scope": "modern_math_physics", + "native_symbol": null, + "source_kind": "function", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, "projection": [ { - "python_name": "p", + "native_c_identity": null, "native_name": "p", "native_position": 0, + "python_name": "p", "python_position": 0, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" + }, + { + "native_c_identity": null, + "native_name": "vx", + "native_position": 1, + "python_name": "vx", + "python_position": 1, + "result_position": null, + "value": null, + "value_cast": null, + "value_kind": "" + }, + { + "native_c_identity": null, + "native_name": "vy", + "native_position": 2, + "python_name": "vy", + "python_position": 2, + "result_position": null, + "value": null, + "value_cast": null, + "value_kind": "" + }, + { + "native_c_identity": null, + "native_name": "vz", + "native_position": 3, + "python_name": "vz", + "python_position": 3, + "result_position": null, + "value": null, + "value_cast": null, + "value_kind": "" } ], - "metadata": {}, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "normalize_particle", - "native_abi": null, - "native_symbol": null, - "native_scope": "modern_math_physics", - "source_kind": "subroutine", - "source_type": null, - "source_location": {}, - "metadata": {} - } + "return_type": { + "coercions": [], + "constraints": [], + "dtype": "Float64", + "metadata": {}, + "name": "Float64", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "e", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null + }, + "visibility": "public" }, { - "name": "hidden_proc", - "native_name": "hidden_proc", "arguments": [ { - "name": "x", - "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, - "metadata": {}, - "storage": { - "kind": "reference", - "read_only": true, - "mutable": false, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "x", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } - }, - "visibility": "public", "default_value": null, "metadata": {}, + "name": "v", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "x", - "native_abi": null, - "native_symbol": null, - "native_scope": "hidden_proc", - "source_kind": "argument", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [ + null + ], "optional": false, + "pointer": false, + "rank": 1, + "shape": [ + ":" + ], + "target": false, + "upper_bounds": [ + null + ], "value": false - } + }, + "native_abi": null, + "native_name": "v", + "native_scope": "scale_vector", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" }, - "optional": false - } - ], - "return_type": null, - "locals": [], - "contracts": [], - "projection": [ - { - "python_name": "x", - "native_name": "x", - "native_position": 0, - "python_position": 0, - "result_position": null, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null - } - ], - "metadata": {}, - "visibility": "private", - "origin": { - "source_language": "fortran", - "native_name": "hidden_proc", - "native_abi": null, - "native_symbol": null, - "native_scope": "modern_math_physics", - "source_kind": "subroutine", - "source_type": null, - "source_location": {}, - "metadata": {} - } - } - ], - "reexports": [], - "prototypes": [], - "overload_sets": [], - "classes": [ - { - "name": "particle", - "native_name": "particle", - "fields": [ - { - "name": "id", "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Float64", "metadata": {}, - "storage": null, + "name": "Float64", "origin": { - "source_language": "fortran", - "native_name": "id", + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + null + ], + "optional": false, + "pointer": false, + "rank": 1, + "shape": [ + ":" + ], + "target": false, + "upper_bounds": [ + null + ], + "value": false + }, "native_abi": null, - "native_symbol": null, + "native_name": "v", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "integer", + "source_language": "fortran", "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "::" + ], + "storage": { + "array": { "allocatable": false, - "pointer": false, - "target": false, + "axes": [ + "strided" + ], + "category": "assumed_shape", "contiguous": false, - "optional": false, - "value": false - } + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, + "pointer": false, + "rank": 1, + "shape": [ + "::" + ], + "source_shape": [ + ":" + ], + "upper_bounds": [] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "alpha", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "id", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "field", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } - }, - { - "name": "mass", + }, + "native_abi": null, + "native_name": "alpha", + "native_scope": "scale_vector", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, "semantic_type": { - "name": "Float64", - "rank": 0, - "dtype": "Float64", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Float64", "metadata": {}, - "storage": null, + "name": "Float64", "origin": { - "source_language": "fortran", - "native_name": "mass", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "real(kind=8)", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } + }, + "native_abi": null, + "native_name": "alpha", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": true } }, - "visibility": "public", + "visibility": "public" + } + ], + "contracts": [], + "locals": [], + "metadata": {}, + "name": "scale_vector", + "native_name": "scale_vector", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "scale_vector", + "native_scope": "modern_math_physics", + "native_symbol": null, + "source_kind": "subroutine", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, + "projection": [ + { + "native_c_identity": null, + "native_name": "v", + "native_position": 0, + "python_name": "v", + "python_position": 0, + "result_position": null, + "value": null, + "value_cast": null, + "value_kind": "" + }, + { + "native_c_identity": null, + "native_name": "alpha", + "native_position": 1, + "python_name": "alpha", + "python_position": 1, + "result_position": null, + "value": null, + "value_cast": null, + "value_kind": "" + } + ], + "return_type": null, + "visibility": "public" + }, + { + "arguments": [ + { "default_value": null, "metadata": {}, + "name": "a", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "mass", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "field", - "source_type": "real(kind=8)", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [ + "1" + ], "optional": false, + "pointer": false, + "rank": 1, + "shape": [ + "3" + ], + "target": false, + "upper_bounds": [ + "3" + ], "value": false - } - } - }, - { - "name": "position", + }, + "native_abi": null, + "native_name": "a", + "native_scope": "dot3", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, "semantic_type": { - "name": "Float64", - "rank": 1, - "dtype": "Float64", - "shape": [ - "3" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Float64", "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": false, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { + "name": "Float64", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ "3" ], - "lower_bounds": [], - "upper_bounds": [], - "source_shape": [ + "target": false, + "upper_bounds": [ "3" ], - "category": "explicit_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" - ], - "contiguous": true, - "allocatable": false, - "pointer": false, - "metadata": {} + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "position", "native_abi": null, - "native_symbol": null, + "native_name": "a", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "real(kind=8)", + "source_language": "fortran", "source_location": {}, - "metadata": { + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "3" + ], + "storage": { + "array": { + "allocatable": false, + "axes": [ + "dense" + ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, + "pointer": false, "rank": 1, "shape": [ "3" ], - "lower_bounds": [ - "1" - ], - "upper_bounds": [ + "source_shape": [ "3" ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + "upper_bounds": [] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": true } }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "b", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "position", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "field", - "source_type": "real(kind=8)", - "source_location": {}, "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ "3" ], - "lower_bounds": [ - "1" - ], + "target": false, "upper_bounds": [ "3" ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, "value": false + }, + "native_abi": null, + "native_name": "b", + "native_scope": "dot3", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, + "semantic_type": { + "coercions": [], + "constraints": [], + "dtype": "Float64", + "metadata": {}, + "name": "Float64", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, + "rank": 1, + "shape": [ + "3" + ], + "target": false, + "upper_bounds": [ + "3" + ], + "value": false + }, + "native_abi": null, + "native_name": "b", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "3" + ], + "storage": { + "array": { + "allocatable": false, + "axes": [ + "dense" + ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, + "pointer": false, + "rank": 1, + "shape": [ + "3" + ], + "source_shape": [ + "3" + ], + "upper_bounds": [] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": true } - } + }, + "visibility": "public" } ], - "methods": [], - "destructors": [], - "overload_sets": [], - "base_classes": [], "contracts": [], - "metadata": { - "fortran_type_attributes": [], - "fortran_component_order": [ - "id", - "mass", - "position" - ], - "fortran_component_facts": [ - { - "name": "id", - "source_type": "integer", - "kind": "", - "rank": 0, - "shape": [], + "locals": [], + "metadata": {}, + "name": "dot3", + "native_name": "dot3", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "dot3", + "native_scope": "modern_math_physics", + "native_symbol": null, + "source_kind": "function", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, + "projection": [ + { + "native_c_identity": null, + "native_name": "a", + "native_position": 0, + "python_name": "a", + "python_position": 0, + "result_position": null, + "value": null, + "value_cast": null, + "value_kind": "" + }, + { + "native_c_identity": null, + "native_name": "b", + "native_position": 1, + "python_name": "b", + "python_position": 1, + "result_position": null, + "value": null, + "value_cast": null, + "value_kind": "" + } + ], + "return_type": { + "coercions": [], + "constraints": [], + "dtype": "Float64", + "metadata": {}, + "name": "Float64", + "origin": { + "metadata": { "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, "pointer": false, - "target": false - }, - { - "name": "mass", - "source_type": "real(kind=8)", - "kind": "8", "rank": 0, "shape": [], - "allocatable": false, - "pointer": false, - "target": false + "target": false, + "upper_bounds": [], + "value": false }, - { - "name": "position", - "source_type": "real(kind=8)", - "kind": "8", - "rank": 1, - "shape": [ - "3" - ], - "allocatable": false, - "pointer": false, - "target": false - } - ], - "fortran_layout_policy": "accessors", - "fortran_direct_layout": false + "native_abi": null, + "native_name": "s", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "particle", - "native_abi": null, - "native_symbol": null, - "native_scope": "modern_math_physics", - "source_kind": "derived_type", - "source_type": null, - "source_location": {}, - "metadata": {} - } + "visibility": "public" }, { - "name": "vector3", - "native_name": "vector3", - "fields": [ + "arguments": [ { - "name": "values", + "default_value": null, + "metadata": {}, + "name": "a", + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1", + "1" + ], + "optional": false, + "pointer": false, + "rank": 2, + "shape": [ + "3", + "3" + ], + "target": false, + "upper_bounds": [ + "3", + "3" + ], + "value": false + }, + "native_abi": null, + "native_name": "a", + "native_scope": "fill_identity3", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, "semantic_type": { - "name": "Float64", - "rank": 1, - "dtype": "Float64", - "shape": [ - "3" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Float64", "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": false, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { - "rank": 1, + "name": "Float64", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1", + "1" + ], + "optional": false, + "pointer": false, + "rank": 2, "shape": [ + "3", "3" ], - "lower_bounds": [], - "upper_bounds": [], - "source_shape": [ + "target": false, + "upper_bounds": [ + "3", "3" ], - "category": "explicit_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" - ], - "contiguous": true, - "allocatable": false, - "pointer": false, - "metadata": {} + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "values", "native_abi": null, - "native_symbol": null, + "native_name": "a", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "real(kind=8)", + "source_language": "fortran", "source_location": {}, - "metadata": { - "rank": 1, + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 2, + "shape": [ + "3", + "3" + ], + "storage": { + "array": { + "allocatable": false, + "axes": [ + "dense", + "dense" + ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": "ORDER_F", + "pointer": false, + "rank": 2, "shape": [ + "3", "3" ], - "lower_bounds": [ - "1" - ], - "upper_bounds": [ + "source_shape": [ + "3", "3" ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + "upper_bounds": [] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "values", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "field", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 1, - "shape": [ - "3" - ], - "lower_bounds": [ - "1" - ], - "upper_bounds": [ - "3" - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } + "visibility": "public" } ], - "methods": [], - "destructors": [], - "overload_sets": [], - "base_classes": [], "contracts": [], - "metadata": { - "fortran_type_attributes": [], - "fortran_component_order": [ - "values" - ], - "fortran_component_facts": [ - { - "name": "values", - "source_type": "real(kind=8)", - "kind": "8", - "rank": 1, - "shape": [ - "3" - ], - "allocatable": false, - "pointer": false, - "target": false - } - ], - "fortran_layout_policy": "accessors", - "fortran_direct_layout": false - }, - "visibility": "public", + "locals": [], + "metadata": {}, + "name": "fill_identity3", + "native_name": "fill_identity3", "origin": { - "source_language": "fortran", - "native_name": "vector3", + "metadata": {}, "native_abi": null, - "native_symbol": null, + "native_name": "fill_identity3", "native_scope": "modern_math_physics", - "source_kind": "derived_type", - "source_type": null, + "native_symbol": null, + "source_kind": "subroutine", + "source_language": "fortran", "source_location": {}, - "metadata": {} - } + "source_type": null + }, + "projection": [ + { + "native_c_identity": null, + "native_name": "a", + "native_position": 0, + "python_name": "a", + "python_position": 0, + "result_position": null, + "value": null, + "value_cast": null, + "value_kind": "" + } + ], + "return_type": null, + "visibility": "public" }, { - "name": "hidden_state", - "native_name": "hidden_state", - "fields": [ + "arguments": [ { - "name": "code", + "default_value": null, + "metadata": {}, + "name": "p", + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "p", + "native_scope": "normalize_particle", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "type(particle)" + }, "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "particle", "metadata": {}, - "storage": null, + "name": "particle", "origin": { - "source_language": "fortran", - "native_name": "code", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } + }, + "native_abi": null, + "native_name": "p", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "type(particle)" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": false } }, - "visibility": "public", + "visibility": "public" + } + ], + "contracts": [], + "locals": [], + "metadata": {}, + "name": "normalize_particle", + "native_name": "normalize_particle", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "normalize_particle", + "native_scope": "modern_math_physics", + "native_symbol": null, + "source_kind": "subroutine", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, + "projection": [ + { + "native_c_identity": null, + "native_name": "p", + "native_position": 0, + "python_name": "p", + "python_position": 0, + "result_position": null, + "value": null, + "value_cast": null, + "value_kind": "" + } + ], + "return_type": null, + "visibility": "public" + }, + { + "arguments": [ + { "default_value": null, "metadata": {}, + "name": "x", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "code", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "field", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false + }, + "native_abi": null, + "native_name": "x", + "native_scope": "hidden_proc", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "semantic_type": { + "coercions": [], + "constraints": [], + "dtype": "Int32", + "metadata": {}, + "name": "Int32", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "x", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": true } - } + }, + "visibility": "public" } ], - "methods": [], - "destructors": [], - "overload_sets": [], - "base_classes": [], "contracts": [], - "metadata": { - "fortran_type_attributes": [], - "fortran_component_order": [ - "code" - ], - "fortran_component_facts": [ - { - "name": "code", - "source_type": "integer", - "kind": "", - "rank": 0, - "shape": [], - "allocatable": false, - "pointer": false, - "target": false - } - ], - "fortran_layout_policy": "accessors", - "fortran_direct_layout": false - }, - "visibility": "private", + "locals": [], + "metadata": {}, + "name": "hidden_proc", + "native_name": "hidden_proc", "origin": { - "source_language": "fortran", - "native_name": "hidden_state", + "metadata": {}, "native_abi": null, - "native_symbol": null, + "native_name": "hidden_proc", "native_scope": "modern_math_physics", - "source_kind": "derived_type", - "source_type": null, + "native_symbol": null, + "source_kind": "subroutine", + "source_language": "fortran", "source_location": {}, - "metadata": {} - } + "source_type": null + }, + "projection": [ + { + "native_c_identity": null, + "native_name": "x", + "native_position": 0, + "python_name": "x", + "python_position": 0, + "result_position": null, + "value": null, + "value_cast": null, + "value_kind": "" + } + ], + "return_type": null, + "visibility": "private" } ], + "imports": [], + "metadata": {}, + "name": "modern_math_physics", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "modern_math_physics", + "native_scope": "modern_math_physics", + "native_symbol": null, + "source_kind": "module", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, + "overload_sets": [], + "prototypes": [], + "reexports": [], "variables": [ { + "default_value": null, + "metadata": {}, "name": "counter", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "counter", + "native_scope": "modern_math_physics", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": null, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "counter", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } + }, + "native_abi": null, + "native_name": "counter", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "hidden_scale", "origin": { - "source_language": "fortran", - "native_name": "counter", - "native_abi": null, - "native_symbol": null, - "native_scope": "modern_math_physics", - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } - }, - { - "name": "hidden_scale", + }, + "native_abi": null, + "native_name": "hidden_scale", + "native_scope": "modern_math_physics", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, "semantic_type": { - "name": "Float64", - "rank": 0, - "dtype": "Float64", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Float64", "metadata": {}, - "storage": null, + "name": "Float64", "origin": { - "source_language": "fortran", - "native_name": "hidden_scale", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "real(kind=8)", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } + }, + "native_abi": null, + "native_name": "hidden_scale", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "private", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "hidden_scale", - "native_abi": null, - "native_symbol": null, - "native_scope": "modern_math_physics", - "source_kind": "variable", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } + "visibility": "private" } - ], - "imports": [], - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "modern_math_physics", - "native_abi": null, - "native_symbol": null, - "native_scope": "modern_math_physics", - "source_kind": "module", - "source_type": null, - "source_location": {}, - "metadata": {} - } + ] } ] } 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 716355eb7..664800772 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 @@ -1,223 +1,224 @@ { "semantic_modules": [ { - "name": "constants_mod", + "classes": [], + "exported_names": null, "functions": [], - "reexports": [], - "prototypes": [], + "imports": [ + { + "items": [ + { + "source": "c_int", + "target": null + }, + { + "source": "c_double", + "target": null + } + ], + "module": "iso_c_binding" + } + ], + "metadata": {}, + "name": "constants_mod", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "constants_mod", + "native_scope": "constants_mod", + "native_symbol": null, + "source_kind": "module", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, "overload_sets": [], - "classes": [], + "prototypes": [], + "reexports": [], "variables": [ { + "default_value": "100", + "metadata": { + "fortran_initializer": "100" + }, "name": "nmax", + "origin": { + "metadata": { + "allocatable": false, + "constant": true, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "nmax", + "native_scope": "constants_mod", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer(kind=c_int)" + }, "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], + "coercions": [], "constraints": [ { - "name": "Constant", - "arguments": [] + "arguments": [], + "name": "Constant" } ], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "dtype": "Int32", "metadata": {}, - "storage": null, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "nmax", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer(kind=c_int)", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, + "constant": true, "contiguous": false, + "lower_bounds": [], "optional": false, - "value": false, - "constant": true - } - } - }, - "visibility": "public", - "default_value": "100", - "metadata": { - "fortran_initializer": "100" + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "nmax", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer(kind=c_int)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, + "visibility": "public" + }, + { + "default_value": null, + "metadata": {}, + "name": "origin", "origin": { - "source_language": "fortran", - "native_name": "nmax", - "native_abi": null, - "native_symbol": null, - "native_scope": "constants_mod", - "source_kind": "variable", - "source_type": "integer(kind=c_int)", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [ + "1" + ], "optional": false, - "value": false, - "constant": true - } - } - }, - { - "name": "origin", + "pointer": false, + "rank": 1, + "shape": [ + "3" + ], + "target": false, + "upper_bounds": [ + "3" + ], + "value": false + }, + "native_abi": null, + "native_name": "origin", + "native_scope": "constants_mod", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=c_double)" + }, "semantic_type": { - "name": "Float64", - "rank": 1, - "dtype": "Float64", - "shape": [ - "3" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Float64", "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": false, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { + "name": "Float64", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + "1" + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ "3" ], - "lower_bounds": [], - "upper_bounds": [], - "source_shape": [ + "target": false, + "upper_bounds": [ "3" ], - "category": "explicit_shape", - "order": null, - "copy_order": null, - "axes": [ - "dense" - ], - "contiguous": true, - "allocatable": false, - "pointer": false, - "metadata": {} + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "origin", "native_abi": null, - "native_symbol": null, + "native_name": "origin", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "real(kind=c_double)", + "source_language": "fortran", "source_location": {}, - "metadata": { + "source_type": "real(kind=c_double)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "3" + ], + "storage": { + "array": { + "allocatable": false, + "axes": [ + "dense" + ], + "category": "explicit_shape", + "contiguous": true, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, + "pointer": false, "rank": 1, "shape": [ "3" ], - "lower_bounds": [ - "1" - ], - "upper_bounds": [ + "source_shape": [ "3" ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + "upper_bounds": [] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "origin", - "native_abi": null, - "native_symbol": null, - "native_scope": "constants_mod", - "source_kind": "variable", - "source_type": "real(kind=c_double)", - "source_location": {}, - "metadata": { - "rank": 1, - "shape": [ - "3" - ], - "lower_bounds": [ - "1" - ], - "upper_bounds": [ - "3" - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } - } - ], - "imports": [ - { - "module": "iso_c_binding", - "items": [ - { - "source": "c_int", - "target": null - }, - { - "source": "c_double", - "target": null - } - ] + "visibility": "public" } - ], - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "constants_mod", - "native_abi": null, - "native_symbol": null, - "native_scope": "constants_mod", - "source_kind": "module", - "source_type": null, - "source_location": {}, - "metadata": {} - } + ] } ] } 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 acaf83ecd..94bc75b32 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 @@ -1,437 +1,438 @@ { "semantic_modules": [ { - "name": "math_mod", + "classes": [], + "exported_names": null, "functions": [ { - "name": "norm2", - "native_name": "norm2", "arguments": [ { + "default_value": null, + "metadata": {}, "name": "x", + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + null + ], + "optional": false, + "pointer": false, + "rank": 1, + "shape": [ + ":" + ], + "target": false, + "upper_bounds": [ + null + ], + "value": false + }, + "native_abi": null, + "native_name": "x", + "native_scope": "norm2", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, "semantic_type": { - "name": "Float64", - "rank": 1, - "dtype": "Float64", - "shape": [ - "::" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Float64", "metadata": {}, - "storage": { - "kind": "array", - "read_only": true, - "mutable": false, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { + "name": "Float64", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + null + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ - "::" - ], - "lower_bounds": [], - "upper_bounds": [], - "source_shape": [ ":" ], - "category": "assumed_shape", - "order": null, - "copy_order": null, - "axes": [ - "strided" + "target": false, + "upper_bounds": [ + null ], - "contiguous": false, - "allocatable": false, - "pointer": false, - "metadata": {} + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "x", "native_abi": null, - "native_symbol": null, + "native_name": "x", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "real(kind=8)", + "source_language": "fortran", "source_location": {}, - "metadata": { + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "::" + ], + "storage": { + "array": { + "allocatable": false, + "axes": [ + "strided" + ], + "category": "assumed_shape", + "contiguous": false, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, + "pointer": false, "rank": 1, "shape": [ - ":" - ], - "lower_bounds": [ - null + "::" ], - "upper_bounds": [ - null + "source_shape": [ + ":" ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } - }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "x", - "native_abi": null, - "native_symbol": null, - "native_scope": "norm2", - "source_kind": "argument", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 1, - "shape": [ - ":" - ], - "lower_bounds": [ - null - ], - "upper_bounds": [ - null - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false + "upper_bounds": [] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": true } }, - "optional": false + "visibility": "public" } ], - "return_type": { - "name": "Float64", - "rank": 0, - "dtype": "Float64", - "shape": [], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "contracts": [], + "locals": [], + "metadata": {}, + "name": "norm2", + "native_name": "norm2", + "origin": { "metadata": {}, - "storage": null, - "origin": { - "source_language": "fortran", - "native_name": "res", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } + "native_abi": null, + "native_name": "norm2", + "native_scope": "math_mod", + "native_symbol": null, + "source_kind": "function", + "source_language": "fortran", + "source_location": {}, + "source_type": null }, - "locals": [], - "contracts": [], "projection": [ { - "python_name": "x", + "native_c_identity": null, "native_name": "x", "native_position": 0, + "python_name": "x", "python_position": 0, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" } ], - "metadata": {}, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "norm2", - "native_abi": null, - "native_symbol": null, - "native_scope": "math_mod", - "source_kind": "function", - "source_type": null, - "source_location": {}, - "metadata": {} - } + "return_type": { + "coercions": [], + "constraints": [], + "dtype": "Float64", + "metadata": {}, + "name": "Float64", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "res", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null + }, + "visibility": "public" }, { - "name": "scale", - "native_name": "scale", "arguments": [ { + "default_value": null, + "metadata": {}, "name": "a", + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "a", + "native_scope": "scale", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, "semantic_type": { - "name": "Float64", - "rank": 0, - "dtype": "Float64", - "shape": [], - "constraints": [], "coercions": [], + "constraints": [], + "dtype": "Float64", + "metadata": {}, + "name": "Float64", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "a", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" + }, "ownership": { - "ownership": "borrowed", + "aliasing": true, "mutable": false, - "aliasing": true + "ownership": "borrowed" }, - "metadata": {}, + "rank": 0, + "shape": [], "storage": { + "array": null, + "calling_convention": null, "kind": "reference", - "read_only": true, + "metadata": {}, "mutable": false, - "pointer_depth": 1, "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "a", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + "pointer_depth": 1, + "read_only": true } }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "x", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "a", - "native_abi": null, - "native_symbol": null, - "native_scope": "scale", - "source_kind": "argument", - "source_type": "real(kind=8)", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [ + null + ], "optional": false, + "pointer": false, + "rank": 1, + "shape": [ + ":" + ], + "target": false, + "upper_bounds": [ + null + ], "value": false - } + }, + "native_abi": null, + "native_name": "x", + "native_scope": "scale", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real(kind=8)" }, - "optional": false - }, - { - "name": "x", "semantic_type": { - "name": "Float64", - "rank": 1, - "dtype": "Float64", - "shape": [ - "::" - ], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, + "constraints": [], + "dtype": "Float64", "metadata": {}, - "storage": { - "kind": "array", - "read_only": false, - "mutable": true, - "pointer_depth": 0, - "ownership": "borrowed", - "array": { + "name": "Float64", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [ + null + ], + "optional": false, + "pointer": false, "rank": 1, "shape": [ - "::" - ], - "lower_bounds": [], - "upper_bounds": [], - "source_shape": [ ":" ], - "category": "assumed_shape", - "order": null, - "copy_order": null, - "axes": [ - "strided" + "target": false, + "upper_bounds": [ + null ], - "contiguous": false, - "allocatable": false, - "pointer": false, - "metadata": {} + "value": false }, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "x", "native_abi": null, - "native_symbol": null, + "native_name": "x", "native_scope": null, + "native_symbol": null, "source_kind": "variable", - "source_type": "real(kind=8)", + "source_language": "fortran", "source_location": {}, - "metadata": { + "source_type": "real(kind=8)" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 1, + "shape": [ + "::" + ], + "storage": { + "array": { + "allocatable": false, + "axes": [ + "strided" + ], + "category": "assumed_shape", + "contiguous": false, + "copy_order": null, + "lower_bounds": [], + "metadata": {}, + "order": null, + "pointer": false, "rank": 1, "shape": [ - ":" - ], - "lower_bounds": [ - null + "::" ], - "upper_bounds": [ - null + "source_shape": [ + ":" ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } - }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "x", - "native_abi": null, - "native_symbol": null, - "native_scope": "scale", - "source_kind": "argument", - "source_type": "real(kind=8)", - "source_location": {}, - "metadata": { - "rank": 1, - "shape": [ - ":" - ], - "lower_bounds": [ - null - ], - "upper_bounds": [ - null - ], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false + "upper_bounds": [] + }, + "calling_convention": null, + "kind": "array", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 0, + "read_only": false } }, - "optional": false + "visibility": "public" } ], - "return_type": null, - "locals": [], "contracts": [], + "locals": [], + "metadata": {}, + "name": "scale", + "native_name": "scale", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "scale", + "native_scope": "math_mod", + "native_symbol": null, + "source_kind": "subroutine", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, "projection": [ { - "python_name": "a", + "native_c_identity": null, "native_name": "a", "native_position": 0, + "python_name": "a", "python_position": 0, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" }, { - "python_name": "x", + "native_c_identity": null, "native_name": "x", "native_position": 1, + "python_name": "x", "python_position": 1, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" } ], - "metadata": {}, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "scale", - "native_abi": null, - "native_symbol": null, - "native_scope": "math_mod", - "source_kind": "subroutine", - "source_type": null, - "source_location": {}, - "metadata": {} - } + "return_type": null, + "visibility": "public" } ], - "reexports": [], - "prototypes": [], - "overload_sets": [], - "classes": [], - "variables": [], "imports": [], "metadata": {}, + "name": "math_mod", "origin": { - "source_language": "fortran", - "native_name": "math_mod", + "metadata": {}, "native_abi": null, - "native_symbol": null, + "native_name": "math_mod", "native_scope": "math_mod", + "native_symbol": null, "source_kind": "module", - "source_type": null, + "source_language": "fortran", "source_location": {}, - "metadata": {} - } + "source_type": null + }, + "overload_sets": [], + "prototypes": [], + "reexports": [], + "variables": [] } ] } 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 904d7b475..bfbbe2c10 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 @@ -1,1813 +1,1814 @@ { "semantic_modules": [ { - "name": "scope_name_reuse_combinations", - "functions": [ + "classes": [ { - "name": "do_work_i", - "native_name": "do_work_i", - "arguments": [ + "base_classes": [], + "contracts": [], + "destructors": [], + "fields": [ { - "name": "same_name", + "default_value": null, + "metadata": {}, + "name": "payload", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "payload", + "native_scope": null, + "native_symbol": null, + "source_kind": "field", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": { - "kind": "reference", - "read_only": false, - "mutable": true, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } + }, + "native_abi": null, + "native_name": "payload", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", + "visibility": "public" + } + ], + "metadata": { + "fortran_component_facts": [ + { + "allocatable": false, + "kind": "", + "name": "payload", + "pointer": false, + "rank": 0, + "shape": [], + "source_type": "integer", + "target": false + } + ], + "fortran_component_order": [ + "payload" + ], + "fortran_direct_layout": false, + "fortran_layout_policy": "accessors", + "fortran_type_attributes": [] + }, + "methods": [], + "name": "same_name", + "native_name": "same_name", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "same_name", + "native_scope": "scope_name_reuse_combinations", + "native_symbol": null, + "source_kind": "derived_type", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, + "overload_sets": [], + "visibility": "public" + } + ], + "exported_names": null, + "functions": [ + { + "arguments": [ + { "default_value": null, "metadata": { "projected_output": true }, + "name": "same_name", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": "do_work_i", - "source_kind": "argument", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": "do_work_i", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "semantic_type": { + "coercions": [], + "constraints": [], + "dtype": "Int32", + "metadata": {}, + "name": "Int32", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": false } }, - "optional": false + "visibility": "public" } ], - "return_type": null, - "locals": [], "contracts": [], + "locals": [], + "metadata": {}, + "name": "do_work_i", + "native_name": "do_work_i", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "do_work_i", + "native_scope": "scope_name_reuse_combinations", + "native_symbol": null, + "source_kind": "subroutine", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, "projection": [ { - "python_name": "same_name", + "native_c_identity": null, "native_name": "same_name", "native_position": 0, + "python_name": "same_name", "python_position": 0, "result_position": 0, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" } ], - "metadata": {}, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "do_work_i", - "native_abi": null, - "native_symbol": null, - "native_scope": "scope_name_reuse_combinations", - "source_kind": "subroutine", - "source_type": null, - "source_location": {}, - "metadata": {} - } + "return_type": null, + "visibility": "public" }, { - "name": "do_work_r", - "native_name": "do_work_r", "arguments": [ { + "default_value": null, + "metadata": {}, "name": "same_name", - "semantic_type": { - "name": "Float32", - "rank": 0, - "dtype": "Float32", - "shape": [], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, - "metadata": {}, - "storage": { - "kind": "reference", - "read_only": true, - "mutable": false, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false }, + "native_abi": null, + "native_name": "same_name", + "native_scope": "do_work_r", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real" + }, + "semantic_type": { + "coercions": [], + "constraints": [], + "dtype": "Float32", + "metadata": {}, + "name": "Float32", "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "real", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } - }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": "do_work_r", - "source_kind": "argument", - "source_type": "real", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": true } }, - "optional": false + "visibility": "public" } ], - "return_type": null, - "locals": [], "contracts": [], + "locals": [], + "metadata": {}, + "name": "do_work_r", + "native_name": "do_work_r", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "do_work_r", + "native_scope": "scope_name_reuse_combinations", + "native_symbol": null, + "source_kind": "subroutine", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, "projection": [ { - "python_name": "same_name", + "native_c_identity": null, "native_name": "same_name", "native_position": 0, + "python_name": "same_name", "python_position": 0, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" } ], - "metadata": {}, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "do_work_r", - "native_abi": null, - "native_symbol": null, - "native_scope": "scope_name_reuse_combinations", - "source_kind": "subroutine", - "source_type": null, - "source_location": {}, - "metadata": {} - } + "return_type": null, + "visibility": "public" }, { - "name": "do_work_l", - "native_name": "do_work_l", "arguments": [ { + "default_value": null, + "metadata": {}, "name": "same_name", + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": "do_work_l", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "logical" + }, "semantic_type": { - "name": "Bool", - "rank": 0, - "dtype": "Bool", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Bool", "metadata": {}, - "storage": { - "kind": "reference", - "read_only": true, - "mutable": false, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, + "name": "Bool", "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "logical", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } - }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": "do_work_l", - "source_kind": "argument", - "source_type": "logical", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "logical" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": true } }, - "optional": false + "visibility": "public" } ], - "return_type": null, - "locals": [], "contracts": [], + "locals": [], + "metadata": {}, + "name": "do_work_l", + "native_name": "do_work_l", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "do_work_l", + "native_scope": "scope_name_reuse_combinations", + "native_symbol": null, + "source_kind": "subroutine", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, "projection": [ { - "python_name": "same_name", + "native_c_identity": null, "native_name": "same_name", "native_position": 0, + "python_name": "same_name", "python_position": 0, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" } ], - "metadata": {}, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "do_work_l", - "native_abi": null, - "native_symbol": null, - "native_scope": "scope_name_reuse_combinations", - "source_kind": "subroutine", - "source_type": null, - "source_location": {}, - "metadata": {} - } + "return_type": null, + "visibility": "public" }, { - "name": "host_one", - "native_name": "host_one", "arguments": [ { - "name": "same_name", - "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, - "metadata": {}, - "storage": { - "kind": "reference", - "read_only": false, - "mutable": true, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } - }, - "visibility": "public", "default_value": null, "metadata": { "projected_output": true }, + "name": "same_name", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": "host_one", - "source_kind": "argument", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": "host_one", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "semantic_type": { + "coercions": [], + "constraints": [], + "dtype": "Int32", + "metadata": {}, + "name": "Int32", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": false } }, - "optional": false + "visibility": "public" } ], - "return_type": null, - "locals": [], "contracts": [], + "locals": [], + "metadata": {}, + "name": "host_one", + "native_name": "host_one", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "host_one", + "native_scope": "scope_name_reuse_combinations", + "native_symbol": null, + "source_kind": "subroutine", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, "projection": [ { - "python_name": "same_name", + "native_c_identity": null, "native_name": "same_name", "native_position": 0, + "python_name": "same_name", "python_position": 0, "result_position": 0, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" } ], - "metadata": {}, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "host_one", - "native_abi": null, - "native_symbol": null, - "native_scope": "scope_name_reuse_combinations", - "source_kind": "subroutine", - "source_type": null, - "source_location": {}, - "metadata": {} - } + "return_type": null, + "visibility": "public" }, { - "name": "host_two", - "native_name": "host_two", "arguments": [ { - "name": "same_name", - "semantic_type": { - "name": "Float32", - "rank": 0, - "dtype": "Float32", - "shape": [], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, - "metadata": {}, - "storage": { - "kind": "reference", - "read_only": false, - "mutable": true, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "real", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } - }, - "visibility": "public", "default_value": null, "metadata": { "projected_output": true }, + "name": "same_name", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": "host_two", - "source_kind": "argument", - "source_type": "real", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": "host_two", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real" + }, + "semantic_type": { + "coercions": [], + "constraints": [], + "dtype": "Float32", + "metadata": {}, + "name": "Float32", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": false } }, - "optional": false + "visibility": "public" } ], - "return_type": null, - "locals": [], "contracts": [], - "projection": [ + "locals": [], + "metadata": {}, + "name": "host_two", + "native_name": "host_two", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "host_two", + "native_scope": "scope_name_reuse_combinations", + "native_symbol": null, + "source_kind": "subroutine", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, + "projection": [ { - "python_name": "same_name", + "native_c_identity": null, "native_name": "same_name", "native_position": 0, + "python_name": "same_name", "python_position": 0, "result_position": 0, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" } ], - "metadata": {}, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "host_two", - "native_abi": null, - "native_symbol": null, - "native_scope": "scope_name_reuse_combinations", - "source_kind": "subroutine", - "source_type": null, - "source_location": {}, - "metadata": {} - } + "return_type": null, + "visibility": "public" }, { - "name": "convert_to_complex", - "native_name": "convert_to_complex", "arguments": [ { + "default_value": null, + "metadata": {}, "name": "same_name", + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": "convert_to_complex", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": { - "kind": "reference", - "read_only": true, - "mutable": false, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } - }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": "convert_to_complex", - "source_kind": "argument", - "source_type": "integer", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": true } }, - "optional": false + "visibility": "public" } ], - "return_type": { - "name": "Complex64", - "rank": 0, - "dtype": "Complex64", - "shape": [], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "contracts": [], + "locals": [], + "metadata": {}, + "name": "convert_to_complex", + "native_name": "convert_to_complex", + "origin": { "metadata": {}, - "storage": null, - "origin": { - "source_language": "fortran", - "native_name": "shared", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "complex", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } + "native_abi": null, + "native_name": "convert_to_complex", + "native_scope": "scope_name_reuse_combinations", + "native_symbol": null, + "source_kind": "function", + "source_language": "fortran", + "source_location": {}, + "source_type": null }, - "locals": [], - "contracts": [], "projection": [ { - "python_name": "same_name", + "native_c_identity": null, "native_name": "same_name", "native_position": 0, + "python_name": "same_name", "python_position": 0, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" } ], - "metadata": {}, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "convert_to_complex", - "native_abi": null, - "native_symbol": null, - "native_scope": "scope_name_reuse_combinations", - "source_kind": "function", - "source_type": null, - "source_location": {}, - "metadata": {} - } + "return_type": { + "coercions": [], + "constraints": [], + "dtype": "Complex64", + "metadata": {}, + "name": "Complex64", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "shared", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "complex" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null + }, + "visibility": "public" }, { - "name": "convert_to_char", - "native_name": "convert_to_char", "arguments": [ { + "default_value": null, + "metadata": {}, "name": "same_name", - "semantic_type": { - "name": "Float32", - "rank": 0, - "dtype": "Float32", - "shape": [], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, - "metadata": {}, - "storage": { - "kind": "reference", - "read_only": true, - "mutable": false, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false }, + "native_abi": null, + "native_name": "same_name", + "native_scope": "convert_to_char", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real" + }, + "semantic_type": { + "coercions": [], + "constraints": [], + "dtype": "Float32", + "metadata": {}, + "name": "Float32", "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "real", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } - }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": "convert_to_char", - "source_kind": "argument", - "source_type": "real", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": true } }, - "optional": false + "visibility": "public" + } + ], + "contracts": [], + "locals": [], + "metadata": {}, + "name": "convert_to_char", + "native_name": "convert_to_char", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "convert_to_char", + "native_scope": "scope_name_reuse_combinations", + "native_symbol": null, + "source_kind": "function", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, + "projection": [ + { + "native_c_identity": null, + "native_name": "same_name", + "native_position": 0, + "python_name": "same_name", + "python_position": 0, + "result_position": null, + "value": null, + "value_cast": null, + "value_kind": "" } ], "return_type": { - "name": "String", - "rank": 0, - "dtype": "String", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "String", "metadata": { "fortran_character_length": "16" }, - "storage": null, + "name": "String", "origin": { - "source_language": "fortran", - "native_name": "shared", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "character(kind=len=16)", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } + }, + "native_abi": null, + "native_name": "shared", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "character(kind=len=16)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "locals": [], - "contracts": [], - "projection": [ - { - "python_name": "same_name", - "native_name": "same_name", - "native_position": 0, - "python_position": 0, - "result_position": null, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null - } - ], - "metadata": {}, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "convert_to_char", - "native_abi": null, - "native_symbol": null, - "native_scope": "scope_name_reuse_combinations", - "source_kind": "function", - "source_type": null, - "source_location": {}, - "metadata": {} - } + "visibility": "public" }, { - "name": "convert_to_logical", - "native_name": "convert_to_logical", "arguments": [ { + "default_value": null, + "metadata": {}, "name": "same_name", + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": "convert_to_logical", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "character(kind=len=*)" + }, "semantic_type": { - "name": "String", - "rank": 0, - "dtype": "String", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "String", "metadata": { "fortran_character_length": "*" }, - "storage": { - "kind": "reference", - "read_only": true, - "mutable": false, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, + "name": "String", "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "character(kind=len=*)", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } - }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": "convert_to_logical", - "source_kind": "argument", - "source_type": "character(kind=len=*)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "character(kind=len=*)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": true } }, - "optional": false + "visibility": "public" } ], - "return_type": { - "name": "Bool", - "rank": 0, - "dtype": "Bool", - "shape": [], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "contracts": [], + "locals": [], + "metadata": {}, + "name": "convert_to_logical", + "native_name": "convert_to_logical", + "origin": { "metadata": {}, - "storage": null, - "origin": { - "source_language": "fortran", - "native_name": "shared", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "logical", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } + "native_abi": null, + "native_name": "convert_to_logical", + "native_scope": "scope_name_reuse_combinations", + "native_symbol": null, + "source_kind": "function", + "source_language": "fortran", + "source_location": {}, + "source_type": null }, - "locals": [], - "contracts": [], "projection": [ { - "python_name": "same_name", + "native_c_identity": null, "native_name": "same_name", "native_position": 0, + "python_name": "same_name", "python_position": 0, "result_position": null, - "value_kind": "", "value": null, "value_cast": null, - "native_c_identity": null + "value_kind": "" } ], - "metadata": {}, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "convert_to_logical", - "native_abi": null, - "native_symbol": null, - "native_scope": "scope_name_reuse_combinations", - "source_kind": "function", - "source_type": null, - "source_location": {}, - "metadata": {} - } + "return_type": { + "coercions": [], + "constraints": [], + "dtype": "Bool", + "metadata": {}, + "name": "Bool", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "shared", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "logical" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null + }, + "visibility": "public" } ], - "reexports": [], - "prototypes": [], + "imports": [], + "metadata": {}, + "name": "scope_name_reuse_combinations", + "origin": { + "metadata": {}, + "native_abi": null, + "native_name": "scope_name_reuse_combinations", + "native_scope": "scope_name_reuse_combinations", + "native_symbol": null, + "source_kind": "module", + "source_language": "fortran", + "source_location": {}, + "source_type": null + }, "overload_sets": [ { "name": "do_work", + "native_scope": "scope_name_reuse_combinations", "procedures": [ { - "name": "do_work_i", - "native_name": "do_work_i", "arguments": [ { - "name": "same_name", - "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": true, - "aliasing": true - }, - "metadata": {}, - "storage": { - "kind": "reference", - "read_only": false, - "mutable": true, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, - "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } - }, - "visibility": "public", "default_value": null, "metadata": { "projected_output": true }, + "name": "same_name", + "optional": false, "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": "do_work_i", - "source_kind": "argument", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": "do_work_i", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "semantic_type": { + "coercions": [], + "constraints": [], + "dtype": "Int32", + "metadata": {}, + "name": "Int32", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": true, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": true, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": false } }, - "optional": false + "visibility": "public" } ], - "return_type": null, - "locals": [], "contracts": [], - "projection": [ - { - "python_name": "same_name", - "native_name": "same_name", - "native_position": 0, - "python_position": 0, - "result_position": 0, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null - } - ], + "locals": [], "metadata": { "fortran_generic_name": "do_work", "overload_kind": "generic", "overload_target": "do_work_i" }, - "visibility": "public", + "name": "do_work_i", + "native_name": "do_work_i", "origin": { - "source_language": "fortran", - "native_name": "do_work_i", + "metadata": {}, "native_abi": null, - "native_symbol": null, + "native_name": "do_work_i", "native_scope": "scope_name_reuse_combinations", + "native_symbol": null, "source_kind": "subroutine", - "source_type": null, + "source_language": "fortran", "source_location": {}, - "metadata": {} - } + "source_type": null + }, + "projection": [ + { + "native_c_identity": null, + "native_name": "same_name", + "native_position": 0, + "python_name": "same_name", + "python_position": 0, + "result_position": 0, + "value": null, + "value_cast": null, + "value_kind": "" + } + ], + "return_type": null, + "visibility": "public" }, { - "name": "do_work_r", - "native_name": "do_work_r", "arguments": [ { + "default_value": null, + "metadata": {}, "name": "same_name", + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": "do_work_r", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "real" + }, "semantic_type": { - "name": "Float32", - "rank": 0, - "dtype": "Float32", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Float32", "metadata": {}, - "storage": { - "kind": "reference", - "read_only": true, - "mutable": false, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, + "name": "Float32", "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "real", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } - }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": "do_work_r", - "source_kind": "argument", - "source_type": "real", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": true } }, - "optional": false + "visibility": "public" } ], - "return_type": null, - "locals": [], "contracts": [], - "projection": [ - { - "python_name": "same_name", - "native_name": "same_name", - "native_position": 0, - "python_position": 0, - "result_position": null, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null - } - ], + "locals": [], "metadata": { "fortran_generic_name": "do_work", "overload_kind": "generic", "overload_target": "do_work_r" }, - "visibility": "public", + "name": "do_work_r", + "native_name": "do_work_r", "origin": { - "source_language": "fortran", - "native_name": "do_work_r", + "metadata": {}, "native_abi": null, - "native_symbol": null, + "native_name": "do_work_r", "native_scope": "scope_name_reuse_combinations", + "native_symbol": null, "source_kind": "subroutine", - "source_type": null, + "source_language": "fortran", "source_location": {}, - "metadata": {} - } + "source_type": null + }, + "projection": [ + { + "native_c_identity": null, + "native_name": "same_name", + "native_position": 0, + "python_name": "same_name", + "python_position": 0, + "result_position": null, + "value": null, + "value_cast": null, + "value_kind": "" + } + ], + "return_type": null, + "visibility": "public" }, { - "name": "do_work_l", - "native_name": "do_work_l", "arguments": [ { + "default_value": null, + "metadata": {}, "name": "same_name", + "optional": false, + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": "do_work_l", + "native_symbol": null, + "source_kind": "argument", + "source_language": "fortran", + "source_location": {}, + "source_type": "logical" + }, "semantic_type": { - "name": "Bool", - "rank": 0, - "dtype": "Bool", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Bool", "metadata": {}, - "storage": { - "kind": "reference", - "read_only": true, - "mutable": false, - "pointer_depth": 1, - "ownership": "borrowed", - "array": null, - "calling_convention": null, - "metadata": {} - }, + "name": "Bool", "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "logical", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } - }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": "do_work_l", - "source_kind": "argument", - "source_type": "logical", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - }, - "optional": false - } - ], - "return_type": null, - "locals": [], - "contracts": [], - "projection": [ - { - "python_name": "same_name", - "native_name": "same_name", - "native_position": 0, - "python_position": 0, - "result_position": null, - "value_kind": "", - "value": null, - "value_cast": null, - "native_c_identity": null - } - ], - "metadata": { - "fortran_generic_name": "do_work", - "overload_kind": "generic", - "overload_target": "do_work_l" - }, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "do_work_l", - "native_abi": null, - "native_symbol": null, - "native_scope": "scope_name_reuse_combinations", - "source_kind": "subroutine", - "source_type": null, - "source_location": {}, - "metadata": {} - } - } - ], - "native_scope": "scope_name_reuse_combinations" - } - ], - "classes": [ - { - "name": "same_name", - "native_name": "same_name", - "fields": [ - { - "name": "payload", - "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], - "constraints": [], - "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, - "metadata": {}, - "storage": null, - "origin": { - "source_language": "fortran", - "native_name": "payload", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, - "metadata": { + }, + "native_abi": null, + "native_name": "same_name", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "logical" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, "rank": 0, "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } + "storage": { + "array": null, + "calling_convention": null, + "kind": "reference", + "metadata": {}, + "mutable": false, + "ownership": "borrowed", + "pointer_depth": 1, + "read_only": true + } + }, + "visibility": "public" } + ], + "contracts": [], + "locals": [], + "metadata": { + "fortran_generic_name": "do_work", + "overload_kind": "generic", + "overload_target": "do_work_l" }, - "visibility": "public", - "default_value": null, - "metadata": {}, + "name": "do_work_l", + "native_name": "do_work_l", "origin": { - "source_language": "fortran", - "native_name": "payload", + "metadata": {}, "native_abi": null, + "native_name": "do_work_l", + "native_scope": "scope_name_reuse_combinations", "native_symbol": null, - "native_scope": null, - "source_kind": "field", - "source_type": "integer", + "source_kind": "subroutine", + "source_language": "fortran", "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false + "source_type": null + }, + "projection": [ + { + "native_c_identity": null, + "native_name": "same_name", + "native_position": 0, + "python_name": "same_name", + "python_position": 0, + "result_position": null, + "value": null, + "value_cast": null, + "value_kind": "" } - } + ], + "return_type": null, + "visibility": "public" } - ], - "methods": [], - "destructors": [], - "overload_sets": [], - "base_classes": [], - "contracts": [], - "metadata": { - "fortran_type_attributes": [], - "fortran_component_order": [ - "payload" - ], - "fortran_component_facts": [ - { - "name": "payload", - "source_type": "integer", - "kind": "", - "rank": 0, - "shape": [], - "allocatable": false, - "pointer": false, - "target": false - } - ], - "fortran_layout_policy": "accessors", - "fortran_direct_layout": false - }, - "visibility": "public", - "origin": { - "source_language": "fortran", - "native_name": "same_name", - "native_abi": null, - "native_symbol": null, - "native_scope": "scope_name_reuse_combinations", - "source_kind": "derived_type", - "source_type": null, - "source_location": {}, - "metadata": {} - } + ] } ], + "prototypes": [], + "reexports": [], "variables": [ { + "default_value": null, + "metadata": {}, "name": "same_name_i", + "origin": { + "metadata": { + "allocatable": false, + "contiguous": false, + "lower_bounds": [], + "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], + "value": false + }, + "native_abi": null, + "native_name": "same_name_i", + "native_scope": "scope_name_reuse_combinations", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, "semantic_type": { - "name": "Int32", - "rank": 0, - "dtype": "Int32", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Int32", "metadata": {}, - "storage": null, + "name": "Int32", "origin": { - "source_language": "fortran", - "native_name": "same_name_i", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } + }, + "native_abi": null, + "native_name": "same_name_i", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "integer" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "same_name_r", "origin": { - "source_language": "fortran", - "native_name": "same_name_i", - "native_abi": null, - "native_symbol": null, - "native_scope": "scope_name_reuse_combinations", - "source_kind": "variable", - "source_type": "integer", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } - }, - { - "name": "same_name_r", + }, + "native_abi": null, + "native_name": "same_name_r", + "native_scope": "scope_name_reuse_combinations", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real" + }, "semantic_type": { - "name": "Float32", - "rank": 0, - "dtype": "Float32", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Float32", "metadata": {}, - "storage": null, + "name": "Float32", "origin": { - "source_language": "fortran", - "native_name": "same_name_r", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "real", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } + }, + "native_abi": null, + "native_name": "same_name_r", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "real" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "same_name_l", "origin": { - "source_language": "fortran", - "native_name": "same_name_r", - "native_abi": null, - "native_symbol": null, - "native_scope": "scope_name_reuse_combinations", - "source_kind": "variable", - "source_type": "real", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } - }, - { - "name": "same_name_l", + }, + "native_abi": null, + "native_name": "same_name_l", + "native_scope": "scope_name_reuse_combinations", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "logical" + }, "semantic_type": { - "name": "Bool", - "rank": 0, - "dtype": "Bool", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Bool", "metadata": {}, - "storage": null, + "name": "Bool", "origin": { - "source_language": "fortran", - "native_name": "same_name_l", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "logical", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } + }, + "native_abi": null, + "native_name": "same_name_l", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "logical" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "same_name_c", "origin": { - "source_language": "fortran", - "native_name": "same_name_l", - "native_abi": null, - "native_symbol": null, - "native_scope": "scope_name_reuse_combinations", - "source_kind": "variable", - "source_type": "logical", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } - }, - { - "name": "same_name_c", + }, + "native_abi": null, + "native_name": "same_name_c", + "native_scope": "scope_name_reuse_combinations", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "complex" + }, "semantic_type": { - "name": "Complex64", - "rank": 0, - "dtype": "Complex64", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "Complex64", "metadata": {}, - "storage": null, + "name": "Complex64", "origin": { - "source_language": "fortran", - "native_name": "same_name_c", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "complex", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } + }, + "native_abi": null, + "native_name": "same_name_c", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "complex" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", + "visibility": "public" + }, + { "default_value": null, "metadata": {}, + "name": "same_name_s", "origin": { - "source_language": "fortran", - "native_name": "same_name_c", - "native_abi": null, - "native_symbol": null, - "native_scope": "scope_name_reuse_combinations", - "source_kind": "variable", - "source_type": "complex", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } - }, - { - "name": "same_name_s", + }, + "native_abi": null, + "native_name": "same_name_s", + "native_scope": "scope_name_reuse_combinations", + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "character(kind=len=8)" + }, "semantic_type": { - "name": "String", - "rank": 0, - "dtype": "String", - "shape": [], - "constraints": [], "coercions": [], - "ownership": { - "ownership": "borrowed", - "mutable": false, - "aliasing": true - }, + "constraints": [], + "dtype": "String", "metadata": { "fortran_character_length": "8" }, - "storage": null, + "name": "String", "origin": { - "source_language": "fortran", - "native_name": "same_name_s", - "native_abi": null, - "native_symbol": null, - "native_scope": null, - "source_kind": "variable", - "source_type": "character(kind=len=8)", - "source_location": {}, "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], "allocatable": false, - "pointer": false, - "target": false, "contiguous": false, + "lower_bounds": [], "optional": false, + "pointer": false, + "rank": 0, + "shape": [], + "target": false, + "upper_bounds": [], "value": false - } - } + }, + "native_abi": null, + "native_name": "same_name_s", + "native_scope": null, + "native_symbol": null, + "source_kind": "variable", + "source_language": "fortran", + "source_location": {}, + "source_type": "character(kind=len=8)" + }, + "ownership": { + "aliasing": true, + "mutable": false, + "ownership": "borrowed" + }, + "rank": 0, + "shape": [], + "storage": null }, - "visibility": "public", - "default_value": null, - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "same_name_s", - "native_abi": null, - "native_symbol": null, - "native_scope": "scope_name_reuse_combinations", - "source_kind": "variable", - "source_type": "character(kind=len=8)", - "source_location": {}, - "metadata": { - "rank": 0, - "shape": [], - "lower_bounds": [], - "upper_bounds": [], - "allocatable": false, - "pointer": false, - "target": false, - "contiguous": false, - "optional": false, - "value": false - } - } + "visibility": "public" } - ], - "imports": [], - "metadata": {}, - "origin": { - "source_language": "fortran", - "native_name": "scope_name_reuse_combinations", - "native_abi": null, - "native_symbol": null, - "native_scope": "scope_name_reuse_combinations", - "source_kind": "module", - "source_type": null, - "source_location": {}, - "metadata": {} - } + ] } ] } diff --git a/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/fixtures/visibility/contracts/fnaming_f90/fnaming_f90.pyi b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/fixtures/visibility/contracts/fnaming_f90/fnaming_f90.pyi index 5085a45b1..755a401e7 100644 --- a/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/fixtures/visibility/contracts/fnaming_f90/fnaming_f90.pyi +++ b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/fixtures/visibility/contracts/fnaming_f90/fnaming_f90.pyi @@ -29,3 +29,5 @@ def lambda__2( ) -> Int32: ... def get_value() -> Int32: ... + +__all__ = ["visible_t", "value", "lambda_", "lambda__2", "get_value"] diff --git a/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/test_package_exports.py b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/test_package_exports.py index 027e682fe..c22daa49d 100644 --- a/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/test_package_exports.py +++ b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/test_package_exports.py @@ -66,8 +66,9 @@ def test_entry_contract_selects_child_flattened_aliased_and_bound_exports(tmp_pa flattened_package = _editable_package(tmp_path, "flattened_api", "flatten.pyi") for leaf_name in ("module1.pyi", "module2.pyi"): leaf = flattened_package / leaf_name + # Removing a declaration removes what the contract publishes with it. leaf.write_text( - leaf.read_text(encoding="utf-8").replace(UPDATE_DECLARATION, "\n"), + leaf.read_text(encoding="utf-8").replace(UPDATE_DECLARATION, "\n").replace(', "update"]', "]"), encoding="utf-8", ) flattened = _build(flattened_package, native_object) diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_import_graph/generated/deep.pyi b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_import_graph/generated/deep.pyi index 1710cf9ce..c156582f4 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_import_graph/generated/deep.pyi +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_import_graph/generated/deep.pyi @@ -4,3 +4,5 @@ from prik.contracts import Addr, Arg, Int32, native_call def deep_func( value: Int32 ) -> Int32: ... + +__all__ = ["deep_func"] diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_import_graph/generated/m1.pyi b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_import_graph/generated/m1.pyi index 65d26870a..96c089dc2 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_import_graph/generated/m1.pyi +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_import_graph/generated/m1.pyi @@ -4,3 +4,5 @@ from prik.contracts import Addr, Arg, Int32, native_call def func( value: Int32 ) -> Int32: ... + +__all__ = ["func"] diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_mixed_module_external/generated/__init__.pyi b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_mixed_module_external/generated/__init__.pyi index b7dc4f895..b7341ebaf 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_mixed_module_external/generated/__init__.pyi +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_mixed_module_external/generated/__init__.pyi @@ -6,3 +6,5 @@ from . import contract_math_mod def external_double( value: Int32 ) -> Int32: ... + +__all__ = ["external_double"] diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_mixed_module_external/generated/contract_math_mod.pyi b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_mixed_module_external/generated/contract_math_mod.pyi index fd60da6b7..f88633754 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_mixed_module_external/generated/contract_math_mod.pyi +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_mixed_module_external/generated/contract_math_mod.pyi @@ -4,3 +4,5 @@ from prik.contracts import Addr, Arg, Int32, native_call def module_increment( value: Int32 ) -> Int32: ... + +__all__ = ["module_increment"] diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_same_name/generated/__init__.pyi b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_same_name/generated/__init__.pyi index d2ff52ec9..54dcee887 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_same_name/generated/__init__.pyi +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_same_name/generated/__init__.pyi @@ -3,3 +3,5 @@ from . import contract_same_name @standalone def external_ping() -> None: ... + +__all__ = ["external_ping"] diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_same_name/generated/contract_same_name.pyi b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_same_name/generated/contract_same_name.pyi index 7e3962687..d6becc630 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_same_name/generated/contract_same_name.pyi +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_same_name/generated/contract_same_name.pyi @@ -1 +1,3 @@ def module_ping() -> None: ... + +__all__ = ["module_ping"] diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_standalone_only/generated/__init__.pyi b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_standalone_only/generated/__init__.pyi index af3318765..1b5b38dde 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_standalone_only/generated/__init__.pyi +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_standalone_only/generated/__init__.pyi @@ -8,3 +8,5 @@ def standalone_ping() -> None: ... def standalone_double( value: Int32 ) -> Int32: ... + +__all__ = ["standalone_ping", "standalone_double"] diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/modern_math_physics.pyi b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/modern_math_physics.pyi index 7c7aeff28..5ff22953b 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/modern_math_physics.pyi +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/modern_math_physics.pyi @@ -55,3 +55,15 @@ def fill_identity3( def normalize_particle( p: particle ) -> None: ... + +__all__ = [ + "particle", + "vector3", + "counter", + "init_particle", + "kinetic_energy", + "scale_vector", + "dot3", + "fill_identity3", + "normalize_particle", +] diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_package_generation.py b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_package_generation.py index 4dda9227a..bf3718bb0 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_package_generation.py +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_package_generation.py @@ -85,7 +85,8 @@ def test_same_named_module_uses_init_entry_and_keeps_externals_at_root(tmp_path: "from prik.contracts import standalone\n" "from . import contract_same_name\n\n" "@standalone\n" - "def external_ping() -> None: ...\n" + "def external_ping() -> None: ...\n\n" + '__all__ = ["external_ping"]\n' ) assert "def module_ping() -> None: ..." in (entry.parent / "contract_same_name.pyi").read_text(encoding="utf-8") 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 c89c6a1da..61e276d0b 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 @@ -316,7 +316,7 @@ def test_emit_imported_derived_type_reference_without_reexporting_class(): assert "p: particle" in code assert "Addr(particle)" not in code assert "class particle" not in code - assert stubs["types_mod"].endswith("class particle(Opaque):\n pass") + assert stubs["types_mod"].endswith('class particle(Opaque):\n pass\n\n__all__ = ["particle"]') def test_emit_procedure_local_imported_derived_types_as_qualified_module_refs(): @@ -426,7 +426,7 @@ def test_emit_bare_use_adds_import_for_opaque_dependency_type(): assert "import types_mod" in stubs["physics"] assert "from .types_mod import particle" in stubs["physics"] - assert stubs["types_mod"].endswith("class particle(Opaque):\n pass") + assert stubs["types_mod"].endswith('class particle(Opaque):\n pass\n\n__all__ = ["particle"]') def test_emit_omits_structured_source_kind_import_without_items(): @@ -843,18 +843,19 @@ def test_import_binds_the_name_a_collision_made_the_declaring_contract_use(): ) assert "def lambda__2(" in stubs["collide_home"] - assert "from .collide_home import lambda__2 as lambda_" in stubs["collide_user"] + assert "from .collide_home import lambda__2" in stubs["collide_user"] + assert '__all__ = ["lambda_"]' in stubs["collide_user"] -def test_a_contract_states_a_reexport_by_aliasing_the_name_it_publishes(): - """An import expresses a declaration; an alias publishes a name. +def test_generated_contract_states_the_names_its_source_publishes(): + """A contract names its whole public surface, not only its re-exports. - A module publishing an imported entity writes it aliased to itself, the way - a stub marks anything it re-exports, so a contract reading this one can tell - the two apart. An import written only to name a type states no such intent. + An import cannot say whether a name is needed to express a declaration or + meant to be published, because a rename reads the same either way. The list + settles it, and is written to be edited. """ home = parse_fortran_source(""" -module publish_home +module surface_home implicit none type :: box integer :: value @@ -863,26 +864,26 @@ def test_a_contract_states_a_reexport_by_aliasing_the_name_it_publishes(): subroutine scale_value(x) integer, intent(inout) :: x end subroutine scale_value -end module publish_home +end module surface_home """) facade = parse_fortran_source(""" -module publish_facade -use publish_home, only : scale_value +module surface_facade +use surface_home, only : scale_value implicit none private public :: scale_value -end module publish_facade +end module surface_facade """) consumer = parse_fortran_source(""" -module publish_consumer -use publish_home, only : box +module surface_consumer +use surface_home, only : crate => box implicit none contains -integer function box_value(item) result(out) -type(box), intent(in) :: item +integer function crate_value(item) result(out) +type(crate), intent(in) :: item out = item%value -end function box_value -end module publish_consumer +end function crate_value +end module surface_consumer """) stubs = emit_module_stubs( @@ -890,6 +891,34 @@ def test_a_contract_states_a_reexport_by_aliasing_the_name_it_publishes(): normalize_fortran_public_names=True, ) - assert "from .publish_home import scale_value as scale_value" in stubs["publish_facade"] - assert "from .publish_home import box\n" in stubs["publish_consumer"] - assert "box as box" not in stubs["publish_consumer"] + # The publishing module names the import; the consuming one does not. + assert stubs["surface_facade"].rstrip().endswith('__all__ = ["scale_value"]') + assert stubs["surface_consumer"].rstrip().endswith('__all__ = ["crate_value"]') + assert "from .surface_home import box as crate" in stubs["surface_consumer"] + assert '__all__ = ["box", "scale_value"]' in stubs["surface_home"] + + +def test_a_published_intrinsic_name_states_no_contract_import(): + """Publishing a name from an intrinsic module publishes nothing here. + + A module may name an intrinsic constant in its `public` statement, and the + module it came from has no contract to read it from. The declaration was + never found, so there is nothing to import and nothing to publish. + """ + source = """ +module kinds_mod +use iso_fortran_env, only : REAL64, INT32 +implicit none +private +public :: REAL64, INT32 +public :: rate +real(REAL64), parameter :: rate = 2.0d0 +end module kinds_mod +""" + + module = fortran_module_to_semantic_module(parse_fortran_source(source)) + code = emit_module(module, normalize_fortran_public_names=True) + + assert [reexport.origin_module for reexport in module.reexports] == ["iso_fortran_env", "iso_fortran_env"] + assert "iso_fortran_env" not in code + assert '__all__ = ["rate"]' in code diff --git a/tests/fortran/modules/end_to_end/fixtures/contracts/fcommon_block_f90/fcommon_block_f90.pyi b/tests/fortran/modules/end_to_end/fixtures/contracts/fcommon_block_f90/fcommon_block_f90.pyi index 8e12c3880..a2136c4fc 100644 --- a/tests/fortran/modules/end_to_end/fixtures/contracts/fcommon_block_f90/fcommon_block_f90.pyi +++ b/tests/fortran/modules/end_to_end/fixtures/contracts/fcommon_block_f90/fcommon_block_f90.pyi @@ -6,3 +6,5 @@ def write_shared( ) -> None: ... def read_shared() -> Int32: ... + +__all__ = ["write_shared", "read_shared"] diff --git a/tests/fortran/modules/end_to_end/fixtures/contracts/fmodule_vars_f90/fmodule_vars_f90.pyi b/tests/fortran/modules/end_to_end/fixtures/contracts/fmodule_vars_f90/fmodule_vars_f90.pyi index 97ebf51a2..244ca68fd 100644 --- a/tests/fortran/modules/end_to_end/fixtures/contracts/fmodule_vars_f90/fmodule_vars_f90.pyi +++ b/tests/fortran/modules/end_to_end/fixtures/contracts/fmodule_vars_f90/fmodule_vars_f90.pyi @@ -30,3 +30,16 @@ def scaled_counter() -> Float64: ... def next_local() -> Int32: ... def black_sum() -> Int32: ... + +__all__ = [ + "rgb_color", + "nmax", + "black", + "counter", + "scale", + "saved_counter", + "summarize", + "scaled_counter", + "next_local", + "black_sum", +] diff --git a/tests/fortran/modules/end_to_end/fixtures/contracts/module_exports/__init__.pyi b/tests/fortran/modules/end_to_end/fixtures/contracts/module_exports/__init__.pyi index c74019363..ec58de443 100644 --- a/tests/fortran/modules/end_to_end/fixtures/contracts/module_exports/__init__.pyi +++ b/tests/fortran/modules/end_to_end/fixtures/contracts/module_exports/__init__.pyi @@ -4,3 +4,5 @@ from . import module2 @prik_standalone def standalone() -> Int32: ... + +__all__ = ["standalone"] diff --git a/tests/fortran/modules/end_to_end/fixtures/contracts/module_exports/module1.pyi b/tests/fortran/modules/end_to_end/fixtures/contracts/module_exports/module1.pyi index 05fbb6358..84aacf5b2 100644 --- a/tests/fortran/modules/end_to_end/fixtures/contracts/module_exports/module1.pyi +++ b/tests/fortran/modules/end_to_end/fixtures/contracts/module_exports/module1.pyi @@ -3,3 +3,5 @@ from prik.contracts import Int32 def func1() -> Int32: ... def update() -> Int32: ... + +__all__ = ["func1", "update"] diff --git a/tests/fortran/modules/end_to_end/fixtures/contracts/module_exports/module2.pyi b/tests/fortran/modules/end_to_end/fixtures/contracts/module_exports/module2.pyi index f3586d831..de26aa5c9 100644 --- a/tests/fortran/modules/end_to_end/fixtures/contracts/module_exports/module2.pyi +++ b/tests/fortran/modules/end_to_end/fixtures/contracts/module_exports/module2.pyi @@ -3,3 +3,5 @@ from prik.contracts import Int32 def func2() -> Int32: ... def update() -> Int32: ... + +__all__ = ["func2", "update"] diff --git a/tests/fortran/modules/end_to_end/fixtures/routing/contracts/modules_direct_bind_c_f90/modules_direct_bind_c_f90.pyi b/tests/fortran/modules/end_to_end/fixtures/routing/contracts/modules_direct_bind_c_f90/modules_direct_bind_c_f90.pyi index fea5b1f7f..9f76b9750 100644 --- a/tests/fortran/modules/end_to_end/fixtures/routing/contracts/modules_direct_bind_c_f90/modules_direct_bind_c_f90.pyi +++ b/tests/fortran/modules/end_to_end/fixtures/routing/contracts/modules_direct_bind_c_f90/modules_direct_bind_c_f90.pyi @@ -13,3 +13,5 @@ def direct_total( def direct_set_counter( value: Int32 ) -> None: ... + +__all__ = ["limit", "counter", "direct_total", "direct_set_counter"] diff --git a/tests/fortran/modules/end_to_end/fixtures/routing/contracts/modules_mixed_bind_c_f90/modules_mixed_bind_c_f90.pyi b/tests/fortran/modules/end_to_end/fixtures/routing/contracts/modules_mixed_bind_c_f90/modules_mixed_bind_c_f90.pyi index 9a85fa681..785699d9c 100644 --- a/tests/fortran/modules/end_to_end/fixtures/routing/contracts/modules_mixed_bind_c_f90/modules_mixed_bind_c_f90.pyi +++ b/tests/fortran/modules/end_to_end/fixtures/routing/contracts/modules_mixed_bind_c_f90/modules_mixed_bind_c_f90.pyi @@ -11,3 +11,5 @@ def direct_total( def adapted_total( value: Int32 ) -> Int32: ... + +__all__ = ["counter", "direct_total", "adapted_total"] 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 4878fe72f..38a0e623d 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 @@ -738,3 +738,63 @@ def test_published_import_binds_the_declaration_a_collision_moved_aside(tmp_path assert module.reexport_collide_mod.lambda__2(np.int32(0)) == np.int32(100) assert module.reexport_collide_user_mod.lambda_ is module.reexport_collide_mod.lambda__2 assert module.reexport_collide_user_mod.lambda_(np.int32(0)) == np.int32(100) + + +def test_a_reexport_binds_one_callable_from_source_and_from_its_contract(tmp_path: Path): + """A published name is an alias, so both routes bind the same object. + + A re-export names a procedure that is already wrapped, whichever way the + build was described. Wrapping it a second time would give one native + procedure two Python objects, and a renamed re-export is no different: the + name it binds changes, not the callable behind it. + """ + import subprocess + import sys + + from tests.fortran._support.wrapper_build import _compiler, _import_from_build_dir + from prik import build_pyi_extension + + source = tmp_path / "reexport.f90" + source.write_text(REEXPORT_SOURCE, encoding="utf-8") + + from_source = _build_source_and_import( + source, + tmp_path / "source_build", + {"bind_c_reexport_wrapper.f90", "reexport_wrapper.c", "reexport_wrapper.h"}, + ) + assert from_source.reexport_facade_mod.scale_value is from_source.reexport_home_mod.scale_value + assert from_source.reexport_renamed_mod.public_scale is from_source.reexport_home_mod.scale_value + + contracts = tmp_path / "contracts" + subprocess.run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--pyi", + str(source), + "--out", + str(contracts), + "--compiler", + _compiler(), + ], + check=True, + capture_output=True, + ) + result = build_pyi_extension( + contracts / "__init__.pyi", + input_compiler=_compiler(), + native_fortran_sources=[str(source)], + output_dir=tmp_path / "contract_build", + output_name="reexport_contract", + ) + from_contract = _import_from_build_dir(result.module_name, result.output_dir) + + assert from_contract.reexport_facade_mod.scale_value is from_contract.reexport_home_mod.scale_value + assert from_contract.reexport_renamed_mod.public_scale is from_contract.reexport_home_mod.scale_value + assert from_contract.reexport_facade_mod.scale_value(np.int32(4)) == np.int32(8) + + # One wrapper defines the procedure on either route. + generated = (result.output_dir / "reexport_contract_wrapper.c").read_text(encoding="utf-8") + assert generated.count("static PyObject * wrap_scale_value") == 1 diff --git a/tests/fortran/optional_arguments/end_to_end/fixtures/contracts/foptional_f90/foptional_f90.pyi b/tests/fortran/optional_arguments/end_to_end/fixtures/contracts/foptional_f90/foptional_f90.pyi index 3cd02cc53..e592ff85f 100644 --- a/tests/fortran/optional_arguments/end_to_end/fixtures/contracts/foptional_f90/foptional_f90.pyi +++ b/tests/fortran/optional_arguments/end_to_end/fixtures/contracts/foptional_f90/foptional_f90.pyi @@ -35,3 +35,5 @@ def optional_status( base: Int32, status: Int32[()] = ... ) -> tuple[Int32, Returns["status", Int32[()]] | None]: ... + +__all__ = ["sample", "summarize", "mutate_optional", "fill_optional", "optional_status"] diff --git a/tests/fortran/optional_arguments/end_to_end/fixtures/contracts/foptional_fixed/__init__.pyi b/tests/fortran/optional_arguments/end_to_end/fixtures/contracts/foptional_fixed/__init__.pyi index bc769ae74..2387de1fc 100644 --- a/tests/fortran/optional_arguments/end_to_end/fixtures/contracts/foptional_fixed/__init__.pyi +++ b/tests/fortran/optional_arguments/end_to_end/fixtures/contracts/foptional_fixed/__init__.pyi @@ -6,3 +6,5 @@ def optional_scale( base: Int32, factor: Int32 = ... ) -> Int32: ... + +__all__ = ["optional_scale"] diff --git a/tests/fortran/optional_arguments/end_to_end/fixtures/routing/contracts/optional_arguments_direct_bind_c_f90/optional_arguments_direct_bind_c_f90.pyi b/tests/fortran/optional_arguments/end_to_end/fixtures/routing/contracts/optional_arguments_direct_bind_c_f90/optional_arguments_direct_bind_c_f90.pyi index 7177288c4..dc255e522 100644 --- a/tests/fortran/optional_arguments/end_to_end/fixtures/routing/contracts/optional_arguments_direct_bind_c_f90/optional_arguments_direct_bind_c_f90.pyi +++ b/tests/fortran/optional_arguments/end_to_end/fixtures/routing/contracts/optional_arguments_direct_bind_c_f90/optional_arguments_direct_bind_c_f90.pyi @@ -11,3 +11,5 @@ def optional_state( def add_optional( value: Float64 = ... ) -> Float64: ... + +__all__ = ["optional_state", "add_optional"] diff --git a/tests/fortran/optional_arguments/end_to_end/fixtures/routing/contracts/optional_arguments_mixed_bind_c_f90/optional_arguments_mixed_bind_c_f90.pyi b/tests/fortran/optional_arguments/end_to_end/fixtures/routing/contracts/optional_arguments_mixed_bind_c_f90/optional_arguments_mixed_bind_c_f90.pyi index 3fdcc4893..a1d6fd393 100644 --- a/tests/fortran/optional_arguments/end_to_end/fixtures/routing/contracts/optional_arguments_mixed_bind_c_f90/optional_arguments_mixed_bind_c_f90.pyi +++ b/tests/fortran/optional_arguments/end_to_end/fixtures/routing/contracts/optional_arguments_mixed_bind_c_f90/optional_arguments_mixed_bind_c_f90.pyi @@ -9,3 +9,5 @@ def direct_optional_state( def adapted_optional_value_state( value: Float64 = ... ) -> Int32: ... + +__all__ = ["direct_optional_state", "adapted_optional_value_state"] diff --git a/tests/fortran/pointers/end_to_end/fixtures/contracts/fpointers_f90/fpointers_f90.pyi b/tests/fortran/pointers/end_to_end/fixtures/contracts/fpointers_f90/fpointers_f90.pyi index af965acdf..8c36684aa 100644 --- a/tests/fortran/pointers/end_to_end/fixtures/contracts/fpointers_f90/fpointers_f90.pyi +++ b/tests/fortran/pointers/end_to_end/fixtures/contracts/fpointers_f90/fpointers_f90.pyi @@ -20,3 +20,5 @@ def pointer_to_values( values: Annotated[Float64[::], Aliased], use_values: Int32 ) -> Annotated[Pointer[Float64[:]], PointerAssociation("runtime")]: ... + +__all__ = ["read_pointer", "pointer_to_scalar", "sum_pointer", "pointer_to_values"] diff --git a/tests/fortran/strings/end_to_end/fixtures/contracts/fcharacter_edges_f90/fcharacter_edges_f90.pyi b/tests/fortran/strings/end_to_end/fixtures/contracts/fcharacter_edges_f90/fcharacter_edges_f90.pyi index 7ab7d3861..c6f6c5ebb 100644 --- a/tests/fortran/strings/end_to_end/fixtures/contracts/fcharacter_edges_f90/fcharacter_edges_f90.pyi +++ b/tests/fortran/strings/end_to_end/fixtures/contracts/fcharacter_edges_f90/fcharacter_edges_f90.pyi @@ -18,3 +18,5 @@ def make_out() -> String[6]: ... def unicode_echo( label: String ) -> String[5]: ... + +__all__ = ["fixed_inout", "assumed_inout", "optional_inout", "make_out", "unicode_echo"] diff --git a/tests/fortran/strings/end_to_end/fixtures/contracts/fstring_descriptors_f90/fstring_descriptors_f90.pyi b/tests/fortran/strings/end_to_end/fixtures/contracts/fstring_descriptors_f90/fstring_descriptors_f90.pyi index 0fab1f839..56b0299ec 100644 --- a/tests/fortran/strings/end_to_end/fixtures/contracts/fstring_descriptors_f90/fstring_descriptors_f90.pyi +++ b/tests/fortran/strings/end_to_end/fixtures/contracts/fstring_descriptors_f90/fstring_descriptors_f90.pyi @@ -124,3 +124,34 @@ def pointer_result() -> Annotated[String[:], Ownership("python"), Transfer("snap @native_call([], result=Pointer(Return(0))) def fixed_pointer_result() -> Annotated[String[4], Ownership("python"), Transfer("snapshot_copy"), Destruction("python_refcount")] | None: ... + +__all__ = [ + "grow", + "shrink", + "drop", + "optional_grow", + "grow_both", + "grow_and_measure", + "measure", + "make", + "measure_fixed_allocatable", + "make_fixed_allocatable", + "relabel_fixed_allocatable", + "drop_fixed_allocatable", + "measure_pointer", + "point_at_static", + "edit_pointer_in_place", + "reassociate_pointer", + "deallocate_pointer", + "nullify_pointer", + "optional_pointer_measure", + "optional_pointer_edit", + "regrow_pointer", + "measure_fixed_pointer", + "point_at_fixed_static", + "relabel_fixed_pointer", + "allocatable_result", + "fixed_allocatable_result", + "pointer_result", + "fixed_pointer_result", +] 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 b4724f0f6..d0825325f 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 @@ -36,3 +36,15 @@ def string_result_padded() -> String[8]: ... @standalone def string_result_declared() -> String[6]: ... + +__all__ = [ + "char_code_default", + "char_code_star1", + "string_len_star8", + "string_len_assumed", + "string_len_entity", + "char_result_default", + "string_result_star8", + "string_result_padded", + "string_result_declared", +] diff --git a/tests/fortran/strings/end_to_end/fixtures/contracts/fstrings_f90/fstrings_f90.pyi b/tests/fortran/strings/end_to_end/fixtures/contracts/fstrings_f90/fstrings_f90.pyi index b94124e47..7afaa30c9 100644 --- a/tests/fortran/strings/end_to_end/fixtures/contracts/fstrings_f90/fstrings_f90.pyi +++ b/tests/fortran/strings/end_to_end/fixtures/contracts/fstrings_f90/fstrings_f90.pyi @@ -54,3 +54,22 @@ def replace_names( def rewrite_storage( label: String[8] ) -> Returns["label", String[8]]: ... + +__all__ = [ + "char_code_default", + "char_code_len1", + "char_code_kind1", + "char_code_c_char", + "string_len_fixed", + "string_len_assumed", + "string_len_c_char", + "char_result_default", + "char_result_c_char", + "string_result_fixed", + "string_result_padded", + "string_result_c_char", + "string_result_deferred", + "fixed_array_extent", + "replace_names", + "rewrite_storage", +] diff --git a/tests/fortran/strings/end_to_end/fixtures/routing/contracts/strings_direct_bind_c_f90/strings_direct_bind_c_f90.pyi b/tests/fortran/strings/end_to_end/fixtures/routing/contracts/strings_direct_bind_c_f90/strings_direct_bind_c_f90.pyi index 4c7c740ba..c151912ec 100644 --- a/tests/fortran/strings/end_to_end/fixtures/routing/contracts/strings_direct_bind_c_f90/strings_direct_bind_c_f90.pyi +++ b/tests/fortran/strings/end_to_end/fixtures/routing/contracts/strings_direct_bind_c_f90/strings_direct_bind_c_f90.pyi @@ -22,3 +22,5 @@ def direct_uppercase_buffer( n: Int32, text: String[1][n] ) -> None: ... + +__all__ = ["direct_char_code", "direct_uppercase", "direct_buffer_sum", "direct_uppercase_buffer"] diff --git a/tests/fortran/strings/end_to_end/fixtures/routing/contracts/strings_mixed_bind_c_f90/strings_mixed_bind_c_f90.pyi b/tests/fortran/strings/end_to_end/fixtures/routing/contracts/strings_mixed_bind_c_f90/strings_mixed_bind_c_f90.pyi index a0e88f3f9..9c04f8478 100644 --- a/tests/fortran/strings/end_to_end/fixtures/routing/contracts/strings_mixed_bind_c_f90/strings_mixed_bind_c_f90.pyi +++ b/tests/fortran/strings/end_to_end/fixtures/routing/contracts/strings_mixed_bind_c_f90/strings_mixed_bind_c_f90.pyi @@ -9,3 +9,5 @@ def direct_char_code( def adapted_fixed_code( text: String[4] ) -> Int32: ... + +__all__ = ["direct_char_code", "adapted_fixed_code"] diff --git a/tests/fortran/subroutines/end_to_end/fixtures/routing/contracts/subroutines_direct_bind_c_f90/subroutines_direct_bind_c_f90.pyi b/tests/fortran/subroutines/end_to_end/fixtures/routing/contracts/subroutines_direct_bind_c_f90/subroutines_direct_bind_c_f90.pyi index ea200cbff..00321755b 100644 --- a/tests/fortran/subroutines/end_to_end/fixtures/routing/contracts/subroutines_direct_bind_c_f90/subroutines_direct_bind_c_f90.pyi +++ b/tests/fortran/subroutines/end_to_end/fixtures/routing/contracts/subroutines_direct_bind_c_f90/subroutines_direct_bind_c_f90.pyi @@ -11,3 +11,5 @@ def direct_reference( def direct_outputs( value: Int32 ) -> tuple[Returns["value", Int32], Int32, Int32]: ... + +__all__ = ["direct_reference", "direct_outputs"] diff --git a/tests/fortran/subroutines/end_to_end/fixtures/routing/contracts/subroutines_mixed_bind_c_f90/subroutines_mixed_bind_c_f90.pyi b/tests/fortran/subroutines/end_to_end/fixtures/routing/contracts/subroutines_mixed_bind_c_f90/subroutines_mixed_bind_c_f90.pyi index b450f4a73..dd10ad7c8 100644 --- a/tests/fortran/subroutines/end_to_end/fixtures/routing/contracts/subroutines_mixed_bind_c_f90/subroutines_mixed_bind_c_f90.pyi +++ b/tests/fortran/subroutines/end_to_end/fixtures/routing/contracts/subroutines_mixed_bind_c_f90/subroutines_mixed_bind_c_f90.pyi @@ -10,3 +10,5 @@ def direct_outputs( def adapted_outputs( value: Int32 ) -> tuple[Returns["value", Int32], Int32]: ... + +__all__ = ["direct_outputs", "adapted_outputs"] From 8c644824250b8468b1959d4c8905c75d42dc1c2d Mon Sep 17 00:00:00 2001 From: said Date: Wed, 16 Sep 2026 04:19:49 +0100 Subject: [PATCH 27/96] Let a stated __all__ name a package's sub-namespaces too A contract's `__all__` is its public surface, and a sub-namespace is part of that surface: `pkg.solver_mod` is an attribute like any other. It was exempt, so an entry naming one module still exposed every module it imported, and a list that read as a selection pruned nothing. The exemption is gone rather than made conditional, and a generated entry states the modules it imports so there is something to take off. Leaving one off keeps the package from exposing it; stating no list still publishes everything the contract reaches. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 5 +++++ prik/cli.py | 3 +++ prik/pipeline/build.py | 21 ++++++------------- .../fallocatable_views_f90/__init__.pyi | 2 ++ .../fscalar_allocatables_f90/__init__.pyi | 2 ++ .../__init__.pyi | 2 ++ .../__init__.pyi | 2 ++ .../fixtures/contracts/array_ops/__init__.pyi | 2 ++ .../farray_contracts_f90/__init__.pyi | 2 ++ .../contracts/farray_results_f90/__init__.pyi | 2 ++ .../contracts/fassumed_rank_f90/__init__.pyi | 2 ++ .../contracts/fmath_arrays_f90/__init__.pyi | 2 ++ .../contracts/multid_arrays/__init__.pyi | 2 ++ .../arrays_direct_bind_c_f90/__init__.pyi | 2 ++ .../arrays_mixed_bind_c_f90/__init__.pyi | 2 ++ .../contracts/fcallback_all_f90/__init__.pyi | 2 ++ .../fcallback_array_f90/__init__.pyi | 2 ++ .../fcallback_scalar_f90/__init__.pyi | 2 ++ .../callbacks_direct_bind_c_f90/__init__.pyi | 2 ++ .../callbacks_mixed_bind_c_f90/__init__.pyi | 2 ++ .../contracts/fbind_value_f90/__init__.pyi | 2 ++ .../fixtures/contracts/fmath_f90/__init__.pyi | 2 ++ .../contracts/fscalar_kinds_f90/__init__.pyi | 2 ++ .../scalar_direct_bind_c_f90/__init__.pyi | 2 ++ .../scalar_mixed_bind_c_f90/__init__.pyi | 2 ++ .../fbind_c_derived_layout_f90/__init__.pyi | 2 ++ .../fborrowed_finalizer_f90/__init__.pyi | 2 ++ .../contracts/fclasses_f90/__init__.pyi | 2 ++ .../contracts/fconstructors_f90/__init__.pyi | 2 ++ .../fderived_boundary_f90/__init__.pyi | 2 ++ .../contracts/finheritance_f90/__init__.pyi | 2 ++ .../fmodule_derived_alias_f90/__init__.pyi | 2 ++ .../__init__.pyi | 2 ++ .../__init__.pyi | 2 ++ .../contracts/fenums_f90/__init__.pyi | 2 ++ .../__init__.pyi | 2 ++ .../__init__.pyi | 2 ++ .../fopenmp_runtime_f90/__init__.pyi | 2 ++ .../fruntime_recursion_f90/__init__.pyi | 2 ++ .../contracts/basic_subroutine/__init__.pyi | 2 ++ .../contracts/foperators_f90/__init__.pyi | 2 ++ .../contracts/foverloads_f90/__init__.pyi | 2 ++ .../contracts/foverloads_fixed/__init__.pyi | 2 ++ .../__init__.pyi | 2 ++ .../__init__.pyi | 2 ++ .../combined_modules/__init__.pyi | 2 ++ .../contracts/runtime_abi/__init__.pyi | 2 ++ .../end_to_end/test_multi_source_builds.py | 6 ++++-- .../fruntime_abi_f90/__init__.pyi | 2 ++ .../source_builds/verbose_api/__init__.pyi | 2 ++ .../cli/pipeline/test_output_contract.py | 6 +++--- .../cli/pipeline/test_stage_dispatch.py | 4 +++- .../contracts/fnaming_f90/__init__.pyi | 2 ++ .../generated/__init__.pyi | 2 ++ .../generated/__init__.pyi | 2 +- .../contract_same_name/generated/__init__.pyi | 2 +- .../test_contract_package_generation.py | 7 ++++--- .../contracts/fcommon_block_f90/__init__.pyi | 2 ++ .../contracts/fmodule_vars_f90/__init__.pyi | 2 ++ .../contracts/module_exports/__init__.pyi | 2 +- .../modules_direct_bind_c_f90/__init__.pyi | 2 ++ .../modules_mixed_bind_c_f90/__init__.pyi | 2 ++ .../contracts/foptional_f90/__init__.pyi | 2 ++ .../__init__.pyi | 2 ++ .../__init__.pyi | 2 ++ .../contracts/fpointers_f90/__init__.pyi | 2 ++ .../fcharacter_edges_f90/__init__.pyi | 2 ++ .../fstring_descriptors_f90/__init__.pyi | 2 ++ .../contracts/fstrings_f90/__init__.pyi | 2 ++ .../strings_direct_bind_c_f90/__init__.pyi | 2 ++ .../strings_mixed_bind_c_f90/__init__.pyi | 2 ++ .../__init__.pyi | 2 ++ .../subroutines_mixed_bind_c_f90/__init__.pyi | 2 ++ 73 files changed, 157 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 877414939..07608db01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ release tags add a leading `v` to the package version. ## Unreleased +- A contract's `__all__` names its sub-namespaces as well, so leaving one off + keeps the package from exposing it. A generated entry contract states the + modules it imports for that reason, and a contract stating no list still + publishes everything it reaches. + - A contract states everything it publishes in a closing `__all__`. An import cannot say whether a name is needed to express a declaration or meant to be published, because a rename reads the same either way, so the list settles it. diff --git a/prik/cli.py b/prik/cli.py index 9ed086c96..9f23ca639 100644 --- a/prik/cli.py +++ b/prik/cli.py @@ -760,6 +760,9 @@ def _source_root_stub(module_names: list[str], external_text: list[str]) -> str: lines = [f"from . import {name}" for name in module_names] import_section = "\n".join(line for line in [contract_section, *lines] if line) sections = [import_section, *external_sections] + # The entry publishes its package tree as well as any standalone name, and + # both are stated so either can be taken off the list. + exported_names = [*module_names, *exported_names] if exported_names: # Each source file states what it publishes, and this entry holds them # all, so one list closes the file the way one does in any contract, diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index cd8268533..d8c24cc4f 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -2047,21 +2047,15 @@ class _PyiExportNode: declarations: list[object] = field(default_factory=list) children: dict[str, _PyiExportNode] = field(default_factory=dict) origins: set[Path] = field(default_factory=set) - namespaces: set[str] = field(default_factory=set) - """Child names attached as sub-namespaces rather than bound entities. - - ``from . import other`` builds the package tree, which is structure rather - than a name the contract publishes, so a stated ``__all__`` names entities - and leaves the tree alone. - """ - unpublished: set[str] = field(default_factory=set) """Names this node resolves but its contract left out of ``__all__``. A contract stating no list publishes everything it reaches, so a name is withheld only where the contract named its surface and left this one off. - Such a name still resolves, because a contract reading from this one has to - resolve what it names; it simply does not become a Python attribute here. + A sub-namespace is part of that surface like anything else, so leaving one + off keeps the package from exposing it. Such a name still resolves, because + a contract reading from this one has to resolve what it names; it simply + does not become a Python attribute here. """ @@ -2193,9 +2187,7 @@ def _merge_relative_import( for item in semantic_import.items: dependency = _relative_import_path(path, semantic_import.module, item.source) dependency_tree = _required_export_tree(dependency, modules_by_path, cache, pending) - local = item.target or item.source - _merge_export_child(tree, local, dependency_tree, origin=path) - tree.namespaces.add(local) + _merge_export_child(tree, item.target or item.source, dependency_tree, origin=path) def _relative_import_path(path: Path, module: str, imported_module: str) -> Path: @@ -2225,8 +2217,7 @@ def _apply_stated_exports(tree: _PyiExportNode, path: Path, exported_names: list missing = [name for name in stated if name not in tree.children] if missing: raise ValueError(f"{path}: __all__ names nothing this contract declares or imports: {missing}") - published = set(stated) | tree.namespaces - tree.unpublished = {name for name in tree.children if name not in published} + tree.unpublished = {name for name in tree.children if name not in set(stated)} def _merge_export_child(tree: _PyiExportNode, name: str, child: _PyiExportNode, *, origin: Path) -> None: diff --git a/tests/fortran/allocatables/end_to_end/fixtures/contracts/fallocatable_views_f90/__init__.pyi b/tests/fortran/allocatables/end_to_end/fixtures/contracts/fallocatable_views_f90/__init__.pyi index c816be03c..14c86c14d 100644 --- a/tests/fortran/allocatables/end_to_end/fixtures/contracts/fallocatable_views_f90/__init__.pyi +++ b/tests/fortran/allocatables/end_to_end/fixtures/contracts/fallocatable_views_f90/__init__.pyi @@ -1 +1,3 @@ from . import fallocatable_views_f90 + +__all__ = ["fallocatable_views_f90"] diff --git a/tests/fortran/allocatables/end_to_end/fixtures/contracts/fscalar_allocatables_f90/__init__.pyi b/tests/fortran/allocatables/end_to_end/fixtures/contracts/fscalar_allocatables_f90/__init__.pyi index 309b09a4c..89fc09f39 100644 --- a/tests/fortran/allocatables/end_to_end/fixtures/contracts/fscalar_allocatables_f90/__init__.pyi +++ b/tests/fortran/allocatables/end_to_end/fixtures/contracts/fscalar_allocatables_f90/__init__.pyi @@ -1 +1,3 @@ from . import fscalar_allocatables_f90 + +__all__ = ["fscalar_allocatables_f90"] diff --git a/tests/fortran/allocatables/end_to_end/fixtures/routing/contracts/allocatables_direct_bind_c_f90/__init__.pyi b/tests/fortran/allocatables/end_to_end/fixtures/routing/contracts/allocatables_direct_bind_c_f90/__init__.pyi index 39218ff83..acb046855 100644 --- a/tests/fortran/allocatables/end_to_end/fixtures/routing/contracts/allocatables_direct_bind_c_f90/__init__.pyi +++ b/tests/fortran/allocatables/end_to_end/fixtures/routing/contracts/allocatables_direct_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import allocatables_direct_bind_c_f90 + +__all__ = ["allocatables_direct_bind_c_f90"] diff --git a/tests/fortran/allocatables/end_to_end/fixtures/routing/contracts/allocatables_mixed_bind_c_f90/__init__.pyi b/tests/fortran/allocatables/end_to_end/fixtures/routing/contracts/allocatables_mixed_bind_c_f90/__init__.pyi index c8952818f..a33f07340 100644 --- a/tests/fortran/allocatables/end_to_end/fixtures/routing/contracts/allocatables_mixed_bind_c_f90/__init__.pyi +++ b/tests/fortran/allocatables/end_to_end/fixtures/routing/contracts/allocatables_mixed_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import allocatables_mixed_bind_c_f90 + +__all__ = ["allocatables_mixed_bind_c_f90"] diff --git a/tests/fortran/arrays/end_to_end/fixtures/contracts/array_ops/__init__.pyi b/tests/fortran/arrays/end_to_end/fixtures/contracts/array_ops/__init__.pyi index 667c6f4eb..a49e5486b 100644 --- a/tests/fortran/arrays/end_to_end/fixtures/contracts/array_ops/__init__.pyi +++ b/tests/fortran/arrays/end_to_end/fixtures/contracts/array_ops/__init__.pyi @@ -1 +1,3 @@ from . import array_ops + +__all__ = ["array_ops"] diff --git a/tests/fortran/arrays/end_to_end/fixtures/contracts/farray_contracts_f90/__init__.pyi b/tests/fortran/arrays/end_to_end/fixtures/contracts/farray_contracts_f90/__init__.pyi index 31640ad5d..bbf6e7954 100644 --- a/tests/fortran/arrays/end_to_end/fixtures/contracts/farray_contracts_f90/__init__.pyi +++ b/tests/fortran/arrays/end_to_end/fixtures/contracts/farray_contracts_f90/__init__.pyi @@ -1 +1,3 @@ from . import farray_contracts_f90 + +__all__ = ["farray_contracts_f90"] diff --git a/tests/fortran/arrays/end_to_end/fixtures/contracts/farray_results_f90/__init__.pyi b/tests/fortran/arrays/end_to_end/fixtures/contracts/farray_results_f90/__init__.pyi index 88b6b5650..19841eadc 100644 --- a/tests/fortran/arrays/end_to_end/fixtures/contracts/farray_results_f90/__init__.pyi +++ b/tests/fortran/arrays/end_to_end/fixtures/contracts/farray_results_f90/__init__.pyi @@ -1 +1,3 @@ from . import farray_results_f90 + +__all__ = ["farray_results_f90"] diff --git a/tests/fortran/arrays/end_to_end/fixtures/contracts/fassumed_rank_f90/__init__.pyi b/tests/fortran/arrays/end_to_end/fixtures/contracts/fassumed_rank_f90/__init__.pyi index 662a9634c..10c910198 100644 --- a/tests/fortran/arrays/end_to_end/fixtures/contracts/fassumed_rank_f90/__init__.pyi +++ b/tests/fortran/arrays/end_to_end/fixtures/contracts/fassumed_rank_f90/__init__.pyi @@ -1 +1,3 @@ from . import fassumed_rank_f90 + +__all__ = ["fassumed_rank_f90"] diff --git a/tests/fortran/arrays/end_to_end/fixtures/contracts/fmath_arrays_f90/__init__.pyi b/tests/fortran/arrays/end_to_end/fixtures/contracts/fmath_arrays_f90/__init__.pyi index 6e2cdf0c1..623777647 100644 --- a/tests/fortran/arrays/end_to_end/fixtures/contracts/fmath_arrays_f90/__init__.pyi +++ b/tests/fortran/arrays/end_to_end/fixtures/contracts/fmath_arrays_f90/__init__.pyi @@ -1 +1,3 @@ from . import fmath_arrays_f90 + +__all__ = ["fmath_arrays_f90"] diff --git a/tests/fortran/arrays/end_to_end/fixtures/contracts/multid_arrays/__init__.pyi b/tests/fortran/arrays/end_to_end/fixtures/contracts/multid_arrays/__init__.pyi index c5242e931..6a6aa9bf1 100644 --- a/tests/fortran/arrays/end_to_end/fixtures/contracts/multid_arrays/__init__.pyi +++ b/tests/fortran/arrays/end_to_end/fixtures/contracts/multid_arrays/__init__.pyi @@ -1 +1,3 @@ from . import multid_arrays + +__all__ = ["multid_arrays"] diff --git a/tests/fortran/arrays/end_to_end/fixtures/routing/contracts/arrays_direct_bind_c_f90/__init__.pyi b/tests/fortran/arrays/end_to_end/fixtures/routing/contracts/arrays_direct_bind_c_f90/__init__.pyi index d6b33aa84..7dad02c8e 100644 --- a/tests/fortran/arrays/end_to_end/fixtures/routing/contracts/arrays_direct_bind_c_f90/__init__.pyi +++ b/tests/fortran/arrays/end_to_end/fixtures/routing/contracts/arrays_direct_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import arrays_direct_bind_c_f90 + +__all__ = ["arrays_direct_bind_c_f90"] diff --git a/tests/fortran/arrays/end_to_end/fixtures/routing/contracts/arrays_mixed_bind_c_f90/__init__.pyi b/tests/fortran/arrays/end_to_end/fixtures/routing/contracts/arrays_mixed_bind_c_f90/__init__.pyi index b00ea2ba4..6c5c43855 100644 --- a/tests/fortran/arrays/end_to_end/fixtures/routing/contracts/arrays_mixed_bind_c_f90/__init__.pyi +++ b/tests/fortran/arrays/end_to_end/fixtures/routing/contracts/arrays_mixed_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import arrays_mixed_bind_c_f90 + +__all__ = ["arrays_mixed_bind_c_f90"] diff --git a/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_all_f90/__init__.pyi b/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_all_f90/__init__.pyi index b25110243..c94255d16 100644 --- a/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_all_f90/__init__.pyi +++ b/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_all_f90/__init__.pyi @@ -1 +1,3 @@ from . import fcallback_all_f90 + +__all__ = ["fcallback_all_f90"] diff --git a/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_array_f90/__init__.pyi b/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_array_f90/__init__.pyi index f291a7423..c8b1722be 100644 --- a/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_array_f90/__init__.pyi +++ b/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_array_f90/__init__.pyi @@ -1 +1,3 @@ from . import fcallback_array_f90 + +__all__ = ["fcallback_array_f90"] diff --git a/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_scalar_f90/__init__.pyi b/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_scalar_f90/__init__.pyi index 5e2288edc..feb141ce1 100644 --- a/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_scalar_f90/__init__.pyi +++ b/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_scalar_f90/__init__.pyi @@ -1 +1,3 @@ from . import fcallback_scalar_f90 + +__all__ = ["fcallback_scalar_f90"] diff --git a/tests/fortran/callbacks/end_to_end/fixtures/routing/contracts/callbacks_direct_bind_c_f90/__init__.pyi b/tests/fortran/callbacks/end_to_end/fixtures/routing/contracts/callbacks_direct_bind_c_f90/__init__.pyi index e0575265b..04fd93957 100644 --- a/tests/fortran/callbacks/end_to_end/fixtures/routing/contracts/callbacks_direct_bind_c_f90/__init__.pyi +++ b/tests/fortran/callbacks/end_to_end/fixtures/routing/contracts/callbacks_direct_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import callbacks_direct_bind_c_f90 + +__all__ = ["callbacks_direct_bind_c_f90"] diff --git a/tests/fortran/callbacks/end_to_end/fixtures/routing/contracts/callbacks_mixed_bind_c_f90/__init__.pyi b/tests/fortran/callbacks/end_to_end/fixtures/routing/contracts/callbacks_mixed_bind_c_f90/__init__.pyi index b715fb96e..4a355398d 100644 --- a/tests/fortran/callbacks/end_to_end/fixtures/routing/contracts/callbacks_mixed_bind_c_f90/__init__.pyi +++ b/tests/fortran/callbacks/end_to_end/fixtures/routing/contracts/callbacks_mixed_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import callbacks_mixed_bind_c_f90 + +__all__ = ["callbacks_mixed_bind_c_f90"] diff --git a/tests/fortran/data_types/end_to_end/fixtures/contracts/fbind_value_f90/__init__.pyi b/tests/fortran/data_types/end_to_end/fixtures/contracts/fbind_value_f90/__init__.pyi index 11d620ebb..081855f99 100644 --- a/tests/fortran/data_types/end_to_end/fixtures/contracts/fbind_value_f90/__init__.pyi +++ b/tests/fortran/data_types/end_to_end/fixtures/contracts/fbind_value_f90/__init__.pyi @@ -1 +1,3 @@ from . import fbind_value_f90 + +__all__ = ["fbind_value_f90"] diff --git a/tests/fortran/data_types/end_to_end/fixtures/contracts/fmath_f90/__init__.pyi b/tests/fortran/data_types/end_to_end/fixtures/contracts/fmath_f90/__init__.pyi index 63123a9e4..4c6f41050 100644 --- a/tests/fortran/data_types/end_to_end/fixtures/contracts/fmath_f90/__init__.pyi +++ b/tests/fortran/data_types/end_to_end/fixtures/contracts/fmath_f90/__init__.pyi @@ -1 +1,3 @@ from . import fmath_f90 + +__all__ = ["fmath_f90"] diff --git a/tests/fortran/data_types/end_to_end/fixtures/contracts/fscalar_kinds_f90/__init__.pyi b/tests/fortran/data_types/end_to_end/fixtures/contracts/fscalar_kinds_f90/__init__.pyi index 60f8fe03a..cc08cafff 100644 --- a/tests/fortran/data_types/end_to_end/fixtures/contracts/fscalar_kinds_f90/__init__.pyi +++ b/tests/fortran/data_types/end_to_end/fixtures/contracts/fscalar_kinds_f90/__init__.pyi @@ -1 +1,3 @@ from . import fscalar_kinds_f90 + +__all__ = ["fscalar_kinds_f90"] diff --git a/tests/fortran/data_types/end_to_end/fixtures/routing/contracts/scalar_direct_bind_c_f90/__init__.pyi b/tests/fortran/data_types/end_to_end/fixtures/routing/contracts/scalar_direct_bind_c_f90/__init__.pyi index 8059f6da2..c2911ac63 100644 --- a/tests/fortran/data_types/end_to_end/fixtures/routing/contracts/scalar_direct_bind_c_f90/__init__.pyi +++ b/tests/fortran/data_types/end_to_end/fixtures/routing/contracts/scalar_direct_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import scalar_direct_bind_c_f90 + +__all__ = ["scalar_direct_bind_c_f90"] diff --git a/tests/fortran/data_types/end_to_end/fixtures/routing/contracts/scalar_mixed_bind_c_f90/__init__.pyi b/tests/fortran/data_types/end_to_end/fixtures/routing/contracts/scalar_mixed_bind_c_f90/__init__.pyi index 3f14d14ca..62536acb4 100644 --- a/tests/fortran/data_types/end_to_end/fixtures/routing/contracts/scalar_mixed_bind_c_f90/__init__.pyi +++ b/tests/fortran/data_types/end_to_end/fixtures/routing/contracts/scalar_mixed_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import scalar_mixed_bind_c_f90 + +__all__ = ["scalar_mixed_bind_c_f90"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fbind_c_derived_layout_f90/__init__.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fbind_c_derived_layout_f90/__init__.pyi index fa1637c7f..55959d684 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fbind_c_derived_layout_f90/__init__.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fbind_c_derived_layout_f90/__init__.pyi @@ -1 +1,3 @@ from . import fbind_c_derived_layout_f90 + +__all__ = ["fbind_c_derived_layout_f90"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fborrowed_finalizer_f90/__init__.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fborrowed_finalizer_f90/__init__.pyi index b1607c137..9c3cef4b9 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fborrowed_finalizer_f90/__init__.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fborrowed_finalizer_f90/__init__.pyi @@ -1 +1,3 @@ from . import fborrowed_finalizer_f90 + +__all__ = ["fborrowed_finalizer_f90"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fclasses_f90/__init__.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fclasses_f90/__init__.pyi index ace582376..137a9dce3 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fclasses_f90/__init__.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fclasses_f90/__init__.pyi @@ -1 +1,3 @@ from . import fclasses_f90 + +__all__ = ["fclasses_f90"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fconstructors_f90/__init__.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fconstructors_f90/__init__.pyi index 544c88188..bec8b65e8 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fconstructors_f90/__init__.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fconstructors_f90/__init__.pyi @@ -1 +1,3 @@ from . import fconstructors_f90 + +__all__ = ["fconstructors_f90"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fderived_boundary_f90/__init__.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fderived_boundary_f90/__init__.pyi index d78156d8f..e7303fbfa 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fderived_boundary_f90/__init__.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fderived_boundary_f90/__init__.pyi @@ -1 +1,3 @@ from . import fderived_boundary_f90 + +__all__ = ["fderived_boundary_f90"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/finheritance_f90/__init__.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/finheritance_f90/__init__.pyi index 51e80c088..262ef465c 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/finheritance_f90/__init__.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/finheritance_f90/__init__.pyi @@ -1 +1,3 @@ from . import finheritance_f90 + +__all__ = ["finheritance_f90"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fmodule_derived_alias_f90/__init__.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fmodule_derived_alias_f90/__init__.pyi index 687107bfc..30261dca9 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fmodule_derived_alias_f90/__init__.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fmodule_derived_alias_f90/__init__.pyi @@ -1 +1,3 @@ from . import fmodule_derived_alias_f90 + +__all__ = ["fmodule_derived_alias_f90"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_direct_bind_c_f90/__init__.pyi b/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_direct_bind_c_f90/__init__.pyi index f370af40b..048039afe 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_direct_bind_c_f90/__init__.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_direct_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import derived_types_direct_bind_c_f90 + +__all__ = ["derived_types_direct_bind_c_f90"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_mixed_bind_c_f90/__init__.pyi b/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_mixed_bind_c_f90/__init__.pyi index bfa13be19..59dd52705 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_mixed_bind_c_f90/__init__.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_mixed_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import derived_types_mixed_bind_c_f90 + +__all__ = ["derived_types_mixed_bind_c_f90"] diff --git a/tests/fortran/enumerations/end_to_end/fixtures/contracts/fenums_f90/__init__.pyi b/tests/fortran/enumerations/end_to_end/fixtures/contracts/fenums_f90/__init__.pyi index 4fad6563f..3073c975f 100644 --- a/tests/fortran/enumerations/end_to_end/fixtures/contracts/fenums_f90/__init__.pyi +++ b/tests/fortran/enumerations/end_to_end/fixtures/contracts/fenums_f90/__init__.pyi @@ -1 +1,3 @@ from . import fenums_f90 + +__all__ = ["fenums_f90"] diff --git a/tests/fortran/enumerations/end_to_end/fixtures/routing/contracts/enumerations_direct_bind_c_f90/__init__.pyi b/tests/fortran/enumerations/end_to_end/fixtures/routing/contracts/enumerations_direct_bind_c_f90/__init__.pyi index 66217b5f6..94969905f 100644 --- a/tests/fortran/enumerations/end_to_end/fixtures/routing/contracts/enumerations_direct_bind_c_f90/__init__.pyi +++ b/tests/fortran/enumerations/end_to_end/fixtures/routing/contracts/enumerations_direct_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import enumerations_direct_bind_c_f90 + +__all__ = ["enumerations_direct_bind_c_f90"] diff --git a/tests/fortran/enumerations/end_to_end/fixtures/routing/contracts/enumerations_mixed_bind_c_f90/__init__.pyi b/tests/fortran/enumerations/end_to_end/fixtures/routing/contracts/enumerations_mixed_bind_c_f90/__init__.pyi index 403943be5..0fe15de44 100644 --- a/tests/fortran/enumerations/end_to_end/fixtures/routing/contracts/enumerations_mixed_bind_c_f90/__init__.pyi +++ b/tests/fortran/enumerations/end_to_end/fixtures/routing/contracts/enumerations_mixed_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import enumerations_mixed_bind_c_f90 + +__all__ = ["enumerations_mixed_bind_c_f90"] diff --git a/tests/fortran/error_handling/end_to_end/fixtures/contracts/fopenmp_runtime_f90/__init__.pyi b/tests/fortran/error_handling/end_to_end/fixtures/contracts/fopenmp_runtime_f90/__init__.pyi index 282a23bc4..dfe8ecebb 100644 --- a/tests/fortran/error_handling/end_to_end/fixtures/contracts/fopenmp_runtime_f90/__init__.pyi +++ b/tests/fortran/error_handling/end_to_end/fixtures/contracts/fopenmp_runtime_f90/__init__.pyi @@ -1 +1,3 @@ from . import fopenmp_runtime_f90 + +__all__ = ["fopenmp_runtime_f90"] diff --git a/tests/fortran/error_handling/end_to_end/fixtures/contracts/fruntime_recursion_f90/__init__.pyi b/tests/fortran/error_handling/end_to_end/fixtures/contracts/fruntime_recursion_f90/__init__.pyi index fa20da782..b531762f4 100644 --- a/tests/fortran/error_handling/end_to_end/fixtures/contracts/fruntime_recursion_f90/__init__.pyi +++ b/tests/fortran/error_handling/end_to_end/fixtures/contracts/fruntime_recursion_f90/__init__.pyi @@ -1 +1,3 @@ from . import fruntime_recursion_f90 + +__all__ = ["fruntime_recursion_f90"] diff --git a/tests/fortran/functions/end_to_end/fixtures/contracts/basic_subroutine/__init__.pyi b/tests/fortran/functions/end_to_end/fixtures/contracts/basic_subroutine/__init__.pyi index c301d4d35..e7dd36493 100644 --- a/tests/fortran/functions/end_to_end/fixtures/contracts/basic_subroutine/__init__.pyi +++ b/tests/fortran/functions/end_to_end/fixtures/contracts/basic_subroutine/__init__.pyi @@ -1 +1,3 @@ from . import m1 + +__all__ = ["m1"] diff --git a/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foperators_f90/__init__.pyi b/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foperators_f90/__init__.pyi index b35830728..65d035f3e 100644 --- a/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foperators_f90/__init__.pyi +++ b/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foperators_f90/__init__.pyi @@ -1 +1,3 @@ from . import foperators_f90 + +__all__ = ["foperators_f90"] diff --git a/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_f90/__init__.pyi b/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_f90/__init__.pyi index 07cef5ed9..2c60219f9 100644 --- a/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_f90/__init__.pyi +++ b/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_f90/__init__.pyi @@ -1 +1,3 @@ from . import foverloads_f90 + +__all__ = ["foverloads_f90"] diff --git a/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_fixed/__init__.pyi b/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_fixed/__init__.pyi index 30aa9d2c0..03a54992e 100644 --- a/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_fixed/__init__.pyi +++ b/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_fixed/__init__.pyi @@ -1 +1,3 @@ from . import foverloads_fixed + +__all__ = ["foverloads_fixed"] diff --git a/tests/fortran/generic_interfaces/end_to_end/fixtures/routing/contracts/generic_interfaces_direct_bind_c_f90/__init__.pyi b/tests/fortran/generic_interfaces/end_to_end/fixtures/routing/contracts/generic_interfaces_direct_bind_c_f90/__init__.pyi index 00d25fbe1..63190ddd6 100644 --- a/tests/fortran/generic_interfaces/end_to_end/fixtures/routing/contracts/generic_interfaces_direct_bind_c_f90/__init__.pyi +++ b/tests/fortran/generic_interfaces/end_to_end/fixtures/routing/contracts/generic_interfaces_direct_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import generic_interfaces_direct_bind_c_f90 + +__all__ = ["generic_interfaces_direct_bind_c_f90"] diff --git a/tests/fortran/generic_interfaces/end_to_end/fixtures/routing/contracts/generic_interfaces_mixed_bind_c_f90/__init__.pyi b/tests/fortran/generic_interfaces/end_to_end/fixtures/routing/contracts/generic_interfaces_mixed_bind_c_f90/__init__.pyi index 62ef2d75f..b34223fee 100644 --- a/tests/fortran/generic_interfaces/end_to_end/fixtures/routing/contracts/generic_interfaces_mixed_bind_c_f90/__init__.pyi +++ b/tests/fortran/generic_interfaces/end_to_end/fixtures/routing/contracts/generic_interfaces_mixed_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import generic_interfaces_mixed_bind_c_f90 + +__all__ = ["generic_interfaces_mixed_bind_c_f90"] diff --git a/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/__init__.pyi b/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/__init__.pyi index d7c63d6cf..91c482ff8 100644 --- a/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/__init__.pyi +++ b/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/__init__.pyi @@ -2,3 +2,5 @@ from . import first_math from . import shared_types from . import second_math from . import box_ops + +__all__ = ["first_math", "shared_types", "second_math", "box_ops"] diff --git a/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/runtime_abi/__init__.pyi b/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/runtime_abi/__init__.pyi index 2907a6325..ee22f99de 100644 --- a/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/runtime_abi/__init__.pyi +++ b/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/runtime_abi/__init__.pyi @@ -1 +1,3 @@ from . import fruntime_abi_f90 + +__all__ = ["fruntime_abi_f90"] 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 bbe9df0e6..48935f226 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 @@ -296,7 +296,8 @@ def test_multi_source_pyi_out_writes_one_flat_combined_package(tmp_path: Path): assert not (package / "second_api").exists() assert not (package / "combined_extensions").exists() assert entry.read_text(encoding="utf-8") == ( - "from . import first_math\nfrom . import shared_types\nfrom . import second_math\nfrom . import box_ops\n" + "from . import first_math\nfrom . import shared_types\nfrom . import second_math\nfrom . import box_ops\n\n" + '__all__ = ["first_math", "shared_types", "second_math", "box_ops"]\n' ) 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") @@ -375,7 +376,8 @@ def test_multi_source_modified_entry_preserves_modules_and_adds_documented_alias "from . import shared_types\n" "from . import second_math\n" "from . import box_ops\n" - "from .second_math import double_after_add as fused_value\n", + "from .second_math import double_after_add as fused_value\n\n" + '__all__ = ["first_math", "shared_types", "second_math", "box_ops", "fused_value"]\n', encoding="utf-8", ) diff --git a/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/fruntime_abi_f90/__init__.pyi b/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/fruntime_abi_f90/__init__.pyi index 2907a6325..ee22f99de 100644 --- a/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/fruntime_abi_f90/__init__.pyi +++ b/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/fruntime_abi_f90/__init__.pyi @@ -1 +1,3 @@ from . import fruntime_abi_f90 + +__all__ = ["fruntime_abi_f90"] diff --git a/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/verbose_api/__init__.pyi b/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/verbose_api/__init__.pyi index 3b46f889d..8085c100d 100644 --- a/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/verbose_api/__init__.pyi +++ b/tests/fortran/infrastructure/building/pipeline/fixtures/generated_contracts/source_builds/verbose_api/__init__.pyi @@ -1 +1,3 @@ from . import verbose_api + +__all__ = ["verbose_api"] diff --git a/tests/fortran/infrastructure/cli/pipeline/test_output_contract.py b/tests/fortran/infrastructure/cli/pipeline/test_output_contract.py index 7b99f160e..1a1d3acf2 100644 --- a/tests/fortran/infrastructure/cli/pipeline/test_output_contract.py +++ b/tests/fortran/infrastructure/cli/pipeline/test_output_contract.py @@ -288,7 +288,7 @@ def test_cli_pyi_out_writes_adjacent_contract_package(tmp_path: Path): assert res.stdout == "" package = tmp_path / "mini" - assert (package / "mini.pyi").read_text(encoding="utf-8") == "from . import m\n" + assert (package / "mini.pyi").read_text(encoding="utf-8") == 'from . import m\n\n__all__ = ["m"]\n' assert "def add1" in (package / "m.pyi").read_text(encoding="utf-8") @@ -316,7 +316,7 @@ def test_cli_pyi_out_writes_modules_inside_source_contract_package(tmp_path: Pat assert result.stdout == "" package = tmp_path / "combined" assert (package / "combined.pyi").read_text(encoding="utf-8") == ( - "from . import first_mod\nfrom . import second_mod\n" + 'from . import first_mod\nfrom . import second_mod\n\n__all__ = ["first_mod", "second_mod"]\n' ) assert "def first(" in (package / "first_mod.pyi").read_text(encoding="utf-8") assert "def second(" in (package / "second_mod.pyi").read_text(encoding="utf-8") @@ -341,7 +341,7 @@ def test_cli_pyi_out_uses_explicit_contract_package_from_inline_code(tmp_path: P assert res.stdout == "" text = (out / "__init__.pyi").read_text(encoding="utf-8") - assert text == "from . import explicit_mod\n" + assert text == 'from . import explicit_mod\n\n__all__ = ["explicit_mod"]\n' leaf_text = (out / "explicit_mod.pyi").read_text(encoding="utf-8") assert "@native_call([Return('x', 0)])" in leaf_text assert "def set_value(" in leaf_text diff --git a/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py b/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py index 529903d7a..95dfc8e74 100644 --- a/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py +++ b/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py @@ -326,7 +326,9 @@ def test_prik_pyi_report_writes_opaque_dependency_stub_for_external_type(tmp_pat assert prik_cli.main() == 0 package = tmp_path / "physics" - assert (package / "__init__.pyi").read_text(encoding="utf-8") == "from . import physics\n" + assert (package / "__init__.pyi").read_text(encoding="utf-8") == ( + 'from . import physics\n\n__all__ = ["physics"]\n' + ) assert (package / "types_mod.pyi").read_text( encoding="utf-8" ) == 'from prik.contracts import Opaque\n\nclass particle(Opaque):\n pass\n\n__all__ = ["particle"]\n' diff --git a/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/fixtures/visibility/contracts/fnaming_f90/__init__.pyi b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/fixtures/visibility/contracts/fnaming_f90/__init__.pyi index 3d5f79513..f325e1b3e 100644 --- a/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/fixtures/visibility/contracts/fnaming_f90/__init__.pyi +++ b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/fixtures/visibility/contracts/fnaming_f90/__init__.pyi @@ -1 +1,3 @@ from . import fnaming_f90 + +__all__ = ["fnaming_f90"] diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_import_graph/generated/__init__.pyi b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_import_graph/generated/__init__.pyi index 773570128..94f9d680a 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_import_graph/generated/__init__.pyi +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_import_graph/generated/__init__.pyi @@ -1,2 +1,4 @@ from . import m1 from . import deep + +__all__ = ["m1", "deep"] diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_mixed_module_external/generated/__init__.pyi b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_mixed_module_external/generated/__init__.pyi index b7341ebaf..27fa1060b 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_mixed_module_external/generated/__init__.pyi +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_mixed_module_external/generated/__init__.pyi @@ -7,4 +7,4 @@ def external_double( value: Int32 ) -> Int32: ... -__all__ = ["external_double"] +__all__ = ["contract_math_mod", "external_double"] diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_same_name/generated/__init__.pyi b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_same_name/generated/__init__.pyi index 54dcee887..387080e03 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_same_name/generated/__init__.pyi +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/fixtures/contracts/contract_same_name/generated/__init__.pyi @@ -4,4 +4,4 @@ from . import contract_same_name @standalone def external_ping() -> None: ... -__all__ = ["external_ping"] +__all__ = ["contract_same_name", "external_ping"] diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_package_generation.py b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_package_generation.py index bf3718bb0..df755ee5a 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_package_generation.py +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_package_generation.py @@ -86,7 +86,7 @@ def test_same_named_module_uses_init_entry_and_keeps_externals_at_root(tmp_path: "from . import contract_same_name\n\n" "@standalone\n" "def external_ping() -> None: ...\n\n" - '__all__ = ["external_ping"]\n' + '__all__ = ["contract_same_name", "external_ping"]\n' ) assert "def module_ping() -> None: ..." in (entry.parent / "contract_same_name.pyi").read_text(encoding="utf-8") @@ -99,7 +99,7 @@ def test_import_graph_generation_writes_entry_and_native_leaves(tmp_path: Path): assert entry == tmp_path / "contracts" / "contract_import_graph" / "__init__.pyi" assert {path.name for path in entry.parent.iterdir()} == {"__init__.pyi", "deep.pyi", "m1.pyi"} - assert entry.read_text(encoding="utf-8") == "from . import m1\nfrom . import deep\n" + assert entry.read_text(encoding="utf-8") == ('from . import m1\nfrom . import deep\n\n__all__ = ["m1", "deep"]\n') def test_multi_module_generation_keeps_each_native_namespace(tmp_path: Path): @@ -126,5 +126,6 @@ def test_multi_module_generation_keeps_each_native_namespace(tmp_path: Path): "contract_right_mod.pyi", } assert (package / "__init__.pyi").read_text(encoding="utf-8") == ( - "from . import contract_left_mod\nfrom . import contract_right_mod\n" + "from . import contract_left_mod\nfrom . import contract_right_mod\n\n" + '__all__ = ["contract_left_mod", "contract_right_mod"]\n' ) diff --git a/tests/fortran/modules/end_to_end/fixtures/contracts/fcommon_block_f90/__init__.pyi b/tests/fortran/modules/end_to_end/fixtures/contracts/fcommon_block_f90/__init__.pyi index 328c8841b..7fc4c9cde 100644 --- a/tests/fortran/modules/end_to_end/fixtures/contracts/fcommon_block_f90/__init__.pyi +++ b/tests/fortran/modules/end_to_end/fixtures/contracts/fcommon_block_f90/__init__.pyi @@ -1 +1,3 @@ from . import fcommon_block_f90 + +__all__ = ["fcommon_block_f90"] diff --git a/tests/fortran/modules/end_to_end/fixtures/contracts/fmodule_vars_f90/__init__.pyi b/tests/fortran/modules/end_to_end/fixtures/contracts/fmodule_vars_f90/__init__.pyi index 9dcc911b3..668208ce1 100644 --- a/tests/fortran/modules/end_to_end/fixtures/contracts/fmodule_vars_f90/__init__.pyi +++ b/tests/fortran/modules/end_to_end/fixtures/contracts/fmodule_vars_f90/__init__.pyi @@ -1 +1,3 @@ from . import fmodule_vars_f90 + +__all__ = ["fmodule_vars_f90"] diff --git a/tests/fortran/modules/end_to_end/fixtures/contracts/module_exports/__init__.pyi b/tests/fortran/modules/end_to_end/fixtures/contracts/module_exports/__init__.pyi index ec58de443..7a1d8c9b3 100644 --- a/tests/fortran/modules/end_to_end/fixtures/contracts/module_exports/__init__.pyi +++ b/tests/fortran/modules/end_to_end/fixtures/contracts/module_exports/__init__.pyi @@ -5,4 +5,4 @@ from . import module2 @prik_standalone def standalone() -> Int32: ... -__all__ = ["standalone"] +__all__ = ["module1", "module2", "standalone"] diff --git a/tests/fortran/modules/end_to_end/fixtures/routing/contracts/modules_direct_bind_c_f90/__init__.pyi b/tests/fortran/modules/end_to_end/fixtures/routing/contracts/modules_direct_bind_c_f90/__init__.pyi index 2ccea4308..b912803a4 100644 --- a/tests/fortran/modules/end_to_end/fixtures/routing/contracts/modules_direct_bind_c_f90/__init__.pyi +++ b/tests/fortran/modules/end_to_end/fixtures/routing/contracts/modules_direct_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import modules_direct_bind_c_f90 + +__all__ = ["modules_direct_bind_c_f90"] diff --git a/tests/fortran/modules/end_to_end/fixtures/routing/contracts/modules_mixed_bind_c_f90/__init__.pyi b/tests/fortran/modules/end_to_end/fixtures/routing/contracts/modules_mixed_bind_c_f90/__init__.pyi index f2ea0d701..ae01b0a58 100644 --- a/tests/fortran/modules/end_to_end/fixtures/routing/contracts/modules_mixed_bind_c_f90/__init__.pyi +++ b/tests/fortran/modules/end_to_end/fixtures/routing/contracts/modules_mixed_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import modules_mixed_bind_c_f90 + +__all__ = ["modules_mixed_bind_c_f90"] diff --git a/tests/fortran/optional_arguments/end_to_end/fixtures/contracts/foptional_f90/__init__.pyi b/tests/fortran/optional_arguments/end_to_end/fixtures/contracts/foptional_f90/__init__.pyi index f7d365f73..8f77c6659 100644 --- a/tests/fortran/optional_arguments/end_to_end/fixtures/contracts/foptional_f90/__init__.pyi +++ b/tests/fortran/optional_arguments/end_to_end/fixtures/contracts/foptional_f90/__init__.pyi @@ -1 +1,3 @@ from . import foptional_f90 + +__all__ = ["foptional_f90"] diff --git a/tests/fortran/optional_arguments/end_to_end/fixtures/routing/contracts/optional_arguments_direct_bind_c_f90/__init__.pyi b/tests/fortran/optional_arguments/end_to_end/fixtures/routing/contracts/optional_arguments_direct_bind_c_f90/__init__.pyi index 9386e4b6a..953bc6910 100644 --- a/tests/fortran/optional_arguments/end_to_end/fixtures/routing/contracts/optional_arguments_direct_bind_c_f90/__init__.pyi +++ b/tests/fortran/optional_arguments/end_to_end/fixtures/routing/contracts/optional_arguments_direct_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import optional_arguments_direct_bind_c_f90 + +__all__ = ["optional_arguments_direct_bind_c_f90"] diff --git a/tests/fortran/optional_arguments/end_to_end/fixtures/routing/contracts/optional_arguments_mixed_bind_c_f90/__init__.pyi b/tests/fortran/optional_arguments/end_to_end/fixtures/routing/contracts/optional_arguments_mixed_bind_c_f90/__init__.pyi index 409f07adb..d89d23a32 100644 --- a/tests/fortran/optional_arguments/end_to_end/fixtures/routing/contracts/optional_arguments_mixed_bind_c_f90/__init__.pyi +++ b/tests/fortran/optional_arguments/end_to_end/fixtures/routing/contracts/optional_arguments_mixed_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import optional_arguments_mixed_bind_c_f90 + +__all__ = ["optional_arguments_mixed_bind_c_f90"] diff --git a/tests/fortran/pointers/end_to_end/fixtures/contracts/fpointers_f90/__init__.pyi b/tests/fortran/pointers/end_to_end/fixtures/contracts/fpointers_f90/__init__.pyi index 87f07ee9f..e991531a3 100644 --- a/tests/fortran/pointers/end_to_end/fixtures/contracts/fpointers_f90/__init__.pyi +++ b/tests/fortran/pointers/end_to_end/fixtures/contracts/fpointers_f90/__init__.pyi @@ -1 +1,3 @@ from . import fpointers_f90 + +__all__ = ["fpointers_f90"] diff --git a/tests/fortran/strings/end_to_end/fixtures/contracts/fcharacter_edges_f90/__init__.pyi b/tests/fortran/strings/end_to_end/fixtures/contracts/fcharacter_edges_f90/__init__.pyi index 668cabd26..3c3f03c6a 100644 --- a/tests/fortran/strings/end_to_end/fixtures/contracts/fcharacter_edges_f90/__init__.pyi +++ b/tests/fortran/strings/end_to_end/fixtures/contracts/fcharacter_edges_f90/__init__.pyi @@ -1 +1,3 @@ from . import fcharacter_edges_f90 + +__all__ = ["fcharacter_edges_f90"] diff --git a/tests/fortran/strings/end_to_end/fixtures/contracts/fstring_descriptors_f90/__init__.pyi b/tests/fortran/strings/end_to_end/fixtures/contracts/fstring_descriptors_f90/__init__.pyi index bf2f33a49..426379f01 100644 --- a/tests/fortran/strings/end_to_end/fixtures/contracts/fstring_descriptors_f90/__init__.pyi +++ b/tests/fortran/strings/end_to_end/fixtures/contracts/fstring_descriptors_f90/__init__.pyi @@ -1 +1,3 @@ from . import fstring_descriptors_f90 + +__all__ = ["fstring_descriptors_f90"] diff --git a/tests/fortran/strings/end_to_end/fixtures/contracts/fstrings_f90/__init__.pyi b/tests/fortran/strings/end_to_end/fixtures/contracts/fstrings_f90/__init__.pyi index 83c4333c3..c7edd646d 100644 --- a/tests/fortran/strings/end_to_end/fixtures/contracts/fstrings_f90/__init__.pyi +++ b/tests/fortran/strings/end_to_end/fixtures/contracts/fstrings_f90/__init__.pyi @@ -1 +1,3 @@ from . import fstrings_f90 + +__all__ = ["fstrings_f90"] diff --git a/tests/fortran/strings/end_to_end/fixtures/routing/contracts/strings_direct_bind_c_f90/__init__.pyi b/tests/fortran/strings/end_to_end/fixtures/routing/contracts/strings_direct_bind_c_f90/__init__.pyi index 776d6c2dd..3e1122b0d 100644 --- a/tests/fortran/strings/end_to_end/fixtures/routing/contracts/strings_direct_bind_c_f90/__init__.pyi +++ b/tests/fortran/strings/end_to_end/fixtures/routing/contracts/strings_direct_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import strings_direct_bind_c_f90 + +__all__ = ["strings_direct_bind_c_f90"] diff --git a/tests/fortran/strings/end_to_end/fixtures/routing/contracts/strings_mixed_bind_c_f90/__init__.pyi b/tests/fortran/strings/end_to_end/fixtures/routing/contracts/strings_mixed_bind_c_f90/__init__.pyi index 109ff5434..72bbdb4f6 100644 --- a/tests/fortran/strings/end_to_end/fixtures/routing/contracts/strings_mixed_bind_c_f90/__init__.pyi +++ b/tests/fortran/strings/end_to_end/fixtures/routing/contracts/strings_mixed_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import strings_mixed_bind_c_f90 + +__all__ = ["strings_mixed_bind_c_f90"] diff --git a/tests/fortran/subroutines/end_to_end/fixtures/routing/contracts/subroutines_direct_bind_c_f90/__init__.pyi b/tests/fortran/subroutines/end_to_end/fixtures/routing/contracts/subroutines_direct_bind_c_f90/__init__.pyi index d3de141ea..4bf6c13f4 100644 --- a/tests/fortran/subroutines/end_to_end/fixtures/routing/contracts/subroutines_direct_bind_c_f90/__init__.pyi +++ b/tests/fortran/subroutines/end_to_end/fixtures/routing/contracts/subroutines_direct_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import subroutines_direct_bind_c_f90 + +__all__ = ["subroutines_direct_bind_c_f90"] diff --git a/tests/fortran/subroutines/end_to_end/fixtures/routing/contracts/subroutines_mixed_bind_c_f90/__init__.pyi b/tests/fortran/subroutines/end_to_end/fixtures/routing/contracts/subroutines_mixed_bind_c_f90/__init__.pyi index 8c2a6e70d..9662c7980 100644 --- a/tests/fortran/subroutines/end_to_end/fixtures/routing/contracts/subroutines_mixed_bind_c_f90/__init__.pyi +++ b/tests/fortran/subroutines/end_to_end/fixtures/routing/contracts/subroutines_mixed_bind_c_f90/__init__.pyi @@ -1 +1,3 @@ from . import subroutines_mixed_bind_c_f90 + +__all__ = ["subroutines_mixed_bind_c_f90"] From c787c84af03ba330a88f96d1c28a978ac5eb54f9 Mon Sep 17 00:00:00 2001 From: said Date: Wed, 16 Sep 2026 07:34:15 +0100 Subject: [PATCH 28/96] Own a re-export by its declaring contract, not by traversal order Three corrections to the export surface, and one wording change. A declaration published from several namespaces was owned by whichever namespace the entry contract reached first. An entry composes a package by importing from it, and the order it does so says nothing about where anything is declared, so a facade listed before the module it reads from became the owner of a procedure it only republishes -- and the wrapper went to the wrong namespace while the identity assertion still held. Ownership now comes from the contract declaring the entity: each contract's own namespace is read from the export tree, and every other publication aliases what that one owns. A wildcard import took every name its dependency held, including names that dependency's `__all__` withheld. It reads the published surface now. A withheld name stays in the tree and reachable by asking for it, because a contract may need one to express a declaration or mean to publish it itself; only what a dependency publishes answers to `*`. The contract route could also publish what a source build cannot. A module variable holds state that stays live where it is declared and a generic is a dispatch surface rather than one object, yet attaching either to a second namespace gave a contract build an attribute the source build never exposes. A generated contract states neither, and a contract asking for one is refused by name rather than quietly differing. `__all__` states a contract's public symbol surface, which is not the same as the set of runtime objects: a prototype belongs on that list and names a callback signature rather than anything the extension exposes. The reference documents what each kind of published name becomes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 16 ++++ docs/user/reference/pyi-format.md | 45 +++++++++-- prik/pipeline/build.py | 70 ++++++++++++++-- prik/printers/pyi.py | 10 +++ prik/semantics/models.py | 7 +- .../end_to_end/test_multi_source_builds.py | 78 ++++++++++++++++++ .../test_authoritative_contract_runtime.py | 80 ++++++++++++++++++- .../pipeline/test_contract_loading.py | 60 ++++++++++++++ .../test_pyi_printer_imports_and_packages.py | 39 +++++++++ 9 files changed, 391 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07608db01..56489fc8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ release tags add a leading `v` to the package version. ## Unreleased +- A re-exported declaration is owned by the contract declaring it, whichever + order an entry contract imports from. The namespace encountered first owned + it, so a facade listed before the module it reads from took ownership of a + procedure it only republishes. + +- A wildcard import reads the surface its dependency publishes, where it used to + take every name that dependency held. A withheld name stays reachable by + asking for it, which a contract needing it to express a declaration -- or + meaning to publish it itself -- still does. + +- A contract publishes only what a build of the same Fortran source can. A + module variable and a generic reach Python in the namespace declaring them, + so a generated contract states neither as a re-export, and a contract asking + for one is refused rather than given a second projection the source route + has no way to produce. + - A contract's `__all__` names its sub-namespaces as well, so leaving one off keeps the package from exposing it. A generated entry contract states the modules it imports for that reason, and a contract stating no list still diff --git a/docs/user/reference/pyi-format.md b/docs/user/reference/pyi-format.md index efa1669ec..c9c9a9d02 100644 --- a/docs/user/reference/pyi-format.md +++ b/docs/user/reference/pyi-format.md @@ -193,11 +193,27 @@ def area(item: box) -> Int32: ... __all__ = ["area"] ``` -The list is the whole public surface, not only the names a contract re-exports. -It settles a question import syntax cannot answer, because one import serves two -purposes: naming a type a declaration needs, and publishing an entity this -contract means to expose. `from .shapes_mod import box as crate` reads the same -whether `crate` avoids a collision or is published under a new name. +The list states the contract's complete public symbol surface, not only the names +it re-exports. It settles a question import syntax cannot answer, because one +import serves two purposes: naming a type a declaration needs, and publishing an +entity this contract means to expose. `from .shapes_mod import box as crate` +reads the same whether `crate` avoids a collision or is published under a new +name. + +A published symbol is not always a Python object the extension exposes. What the +name declares decides how publishing it appears: + +| Published symbol | How it appears | +| --- | --- | +| Procedure | A runtime callable. | +| Derived type | A runtime type. | +| Package sub-namespace | A runtime namespace attribute. | +| Prototype | A callback signature contracts name, with no runtime object. | +| Module variable | Only in the namespace declaring it; republishing is unsupported. | +| Generic interface | Only in the namespace declaring it; republishing is unsupported. | + +A name whose kind cannot reach a second namespace is refused rather than +published differently from a build of the same Fortran source. PRIK writes the list into every generated contract, holding what the Fortran source publishes: the module's own public declarations, and any imported name it @@ -211,7 +227,24 @@ names in a `public` statement. Edit it freely. | Remove `__all__` | Publishes everything the contract reaches, its declarations and its imports alike. | A name in `__all__` must be one the contract declares or imports; naming -anything else is rejected before wrapper planning. +anything else is rejected before wrapper planning, so renaming a declaration +means renaming what the contract publishes. + +A wildcard import reads the surface its dependency publishes: + +```python +from .shapes_mod import * +``` + +brings in what `shapes_mod` states in its own `__all__` and nothing it withheld. +A withheld name stays reachable by asking for it, which a contract needing it to +express a declaration -- or meaning to publish it itself -- still can: + +```python +from .shapes_mod import crate + +__all__ = ["crate"] +``` ### Contract Import Graph diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index d8c24cc4f..664d0125f 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -2074,11 +2074,15 @@ 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) + namespace_by_contract = _namespace_by_contract(tree, entry) # A declaration published from more than one namespace is one entity, so - # the namespaces after the first bind what the first already exports rather - # than each wrapping the native declaration again. A rename changes the - # name a namespace binds, never the object behind it. - for module in modules_by_path.values(): + # the namespaces beyond its own bind what its own already exports rather + # than each wrapping the native declaration again. Which namespace owns it + # is settled by the contract declaring it, never by the order an entry + # happens to import from. A rename changes the name a namespace binds, + # never the object behind it. + for path, module in modules_by_path.items(): + home = namespace_by_contract.get(path) for declaration, entity_kind in ( *((item, "derived_type") for item in module.classes), *((item, "procedure") for item in module.functions), @@ -2086,9 +2090,13 @@ def _apply_pyi_python_exports(entry: Path, modules_by_path: dict[Path, SemanticM exports = _declaration_exports(declaration) if len(exports) < 2: continue - primary = exports[0] + primary = next( + (export for export in exports if tuple(export["namespace"]) == home), + exports[0], + ) + aliases = [export for export in exports if export is not primary] source_namespace = ".".join(primary["namespace"]) - for alias in exports[1:]: + for alias in aliases: module.reexports.append( SemanticReexport( local_name=alias["name"], @@ -2099,6 +2107,7 @@ def _apply_pyi_python_exports(entry: Path, modules_by_path: dict[Path, SemanticM ) ) exports[:] = [primary] + _reject_unsupported_republication(path, module) def _pyi_export_tree( @@ -2176,7 +2185,11 @@ def _merge_relative_import( dependency_tree = _required_export_tree(dependency, modules_by_path, cache, pending) for item in semantic_import.items: if item.source == "*": + # A wildcard takes the surface the dependency publishes. A name + # it withheld is still reachable, but only by asking for it. for name, child in dependency_tree.children.items(): + if name in dependency_tree.unpublished: + continue _merge_export_child(tree, name, child, origin=path) continue if item.source not in dependency_tree.children: @@ -2239,6 +2252,51 @@ def _merge_export_child(tree: _PyiExportNode, name: str, child: _PyiExportNode, ) +def _reject_unsupported_republication(path: Path, module: SemanticModule) -> None: + """Refuse a published name whose kind reaches Python through one namespace. + + A module variable holds state that stays live where it is declared, and a + generic is a dispatch surface rather than one object, so neither can be + bound a second time. A source build publishes neither, and a contract that + asks for it says what no build can do rather than quietly differing. + """ + for declaration, kind in ( + *((item, "module variable") for item in module.variables), + *((item, "generic") for item in module.overload_sets), + ): + exports = _declaration_exports(declaration) + if len(exports) < 2: + continue + namespaces = ", ".join(".".join(export["namespace"]) or "" for export in exports) + raise ValueError( + f"{path}: {kind} {declaration.name!r} is published by more than one contract " + f"({namespaces}); republishing this kind is not supported" + ) + + +def _namespace_by_contract(tree: _PyiExportNode, entry: Path) -> dict[Path, tuple[str, ...]]: + """Return the Python namespace each contract's own declarations live in. + + A contract publishes its declarations in one namespace of its own, and any + other namespace publishing them is republishing what that one owns. The + entry contract owns the package root; every other namespace node names the + contract it was built from. + """ + namespaces: dict[Path, tuple[str, ...]] = {entry: ()} + + def walk(node: _PyiExportNode, namespace: tuple[str, ...]) -> None: + for name, child in node.children.items(): + if not child.children: + continue + child_namespace = (*namespace, name) + for origin in child.origins: + namespaces.setdefault(origin, child_namespace) + walk(child, child_namespace) + + walk(tree, ()) + return namespaces + + def _record_pyi_exports(tree: _PyiExportNode, namespace: tuple[str, ...] = ()) -> None: """Write resolved namespace paths from an export tree into declarations. diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index 63243b355..2aa42ae98 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -156,6 +156,11 @@ def _public_owner_key(owner: object) -> object: return id(owner) +# Publication of these kinds has no runtime form yet, so a generated contract +# does not claim it. +_UNPUBLISHABLE_REEXPORT_KINDS = frozenset({"variable", "generic"}) + + class PyiPrinter(ClassVisitor): """Emit editable Python stub text from semantic IR models. @@ -634,6 +639,11 @@ def _module_exported_names( for reexport in module.reexports: if self._is_source_kind_import(str(reexport.origin_module)): continue + if reexport.entity_kind in _UNPUBLISHABLE_REEXPORT_KINDS: + # A live module variable and a generic dispatcher have no single + # object another namespace can bind, so a source build publishes + # neither and a contract generated from it states neither. + continue # A prototype keeps its declared spelling wherever it is written, so # the name published for it is the one its import binds. local = str(reexport.local_name) diff --git a/prik/semantics/models.py b/prik/semantics/models.py index 0dfa1227c..f3004941b 100644 --- a/prik/semantics/models.py +++ b/prik/semantics/models.py @@ -716,7 +716,12 @@ class SemanticModule: imports: list[str | SemanticImport] = field(default_factory=list) exported_names: list[str] | None = None - """Every name this module publishes, or ``None`` when it states no list. + """This module's public symbol surface, or ``None`` when it states no list. + + A published symbol is not always one Python object: a prototype names a + callback signature that contracts refer to and nothing exposes at runtime, + while a procedure names a callable. What the name declares decides how + publishing it appears. A contract states its whole public surface here, so a name it imports is published when it is listed and stays a dependency when it is not. The list 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 48935f226..fcf8766b7 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 @@ -456,3 +456,81 @@ def test_makefile_mode_reproduces_multi_source_build(tmp_path: Path): assert module.second_api.double_value(np.int32(4)) == 10 finally: sys.path.remove(str(tmp_path)) + + +REEXPORT_OWNERSHIP_SOURCE = """\ +module owner_mod + implicit none +contains + subroutine scale_twice(value, scaled) + integer, intent(in) :: value + integer, intent(out) :: scaled + scaled = value * 2 + end subroutine scale_twice +end module owner_mod + +module facade_mod + use owner_mod, only : scale_twice + implicit none + private + public :: scale_twice +end module facade_mod + +module renaming_mod + use owner_mod, only : doubled => scale_twice + implicit none + private + public :: doubled +end module renaming_mod +""" + + +def _entry_listing(package: Path, modules: list[str]) -> None: + """Rewrite a package entry so it imports its modules in one stated order.""" + lines = "".join(f"from . import {name}\n" for name in modules) + stated = ", ".join(f'"{name}"' for name in modules) + (package / "__init__.pyi").write_text(f"{lines}\n__all__ = [{stated}]\n", encoding="utf-8") + + +@pytest.mark.parametrize( + "order", + [ + pytest.param(["owner_mod", "facade_mod", "renaming_mod"], id="declaration-first"), + pytest.param(["renaming_mod", "facade_mod", "owner_mod"], id="declaration-last"), + ], +) +def test_reexport_is_owned_by_its_declaring_contract_whatever_the_entry_lists_first( + order: list[str], + tmp_path: Path, +): + """The contract declaring a procedure owns it, whichever entry names it first. + + An entry composes a package by importing from it, and the order it does so + is not a statement about where anything is declared. Reading ownership from + that order lets a facade own what it only republishes, and the wrapper then + belongs to the wrong namespace. + """ + source = tmp_path / "ownership.f90" + source.write_text(REEXPORT_OWNERSHIP_SOURCE, encoding="utf-8") + package = tmp_path / "contracts" + subprocess.run( + [sys.executable, "-m", "prik", "generate", "--pyi", str(source), "--out", str(package)], + capture_output=True, + text=True, + check=True, + ) + entry = package / "__init__.pyi" + _entry_listing(package, order) + + native_objects = _compile_native_objects((source,), tmp_path / "native") + module, _payload = _build_contract(entry, native_objects, tmp_path / "build", output_name="ownership") + + assert module.facade_mod.scale_twice is module.owner_mod.scale_twice + assert module.renaming_mod.doubled is module.owner_mod.scale_twice + assert module.facade_mod.scale_twice(np.int32(21)) == np.int32(42) + + # One wrapper defines the procedure, and the declaring namespace holds it. + generated = next((tmp_path / "build").rglob("*_wrapper.c")).read_text(encoding="utf-8") + assert generated.count("static PyObject * wrap_scale_twice") == 1 + assert 'prik_bind_namespace_alias(namespace_facade_mod, "scale_twice", namespace_owner_mod' in generated + assert 'prik_bind_namespace_alias(namespace_renaming_mod, "doubled", namespace_owner_mod' in generated diff --git a/tests/fortran/infrastructure/semantic_pyi/end_to_end/test_authoritative_contract_runtime.py b/tests/fortran/infrastructure/semantic_pyi/end_to_end/test_authoritative_contract_runtime.py index 15bcff10c..d4d754258 100644 --- a/tests/fortran/infrastructure/semantic_pyi/end_to_end/test_authoritative_contract_runtime.py +++ b/tests/fortran/infrastructure/semantic_pyi/end_to_end/test_authoritative_contract_runtime.py @@ -9,7 +9,7 @@ import pytest from tests.fortran._support.pyi_fixtures import assert_generated_pyi_package_matches_fixture -from tests.fortran._support.wrapper_build import _compiler +from tests.fortran._support.wrapper_build import _compiler, _import_from_build_dir from prik import build_pyi_extension from prik.compiler.objects import ObjectFile from prik.pipeline.build import _new_compiler @@ -95,3 +95,81 @@ def test_generated_contract_rebuilds_without_native_source_fallback(compiled_con assert not hasattr(module, "module_increment") assert module.contract_math_mod.module_increment(np.int32(4)) == np.int32(5) assert module.external_double(np.int32(4)) == np.int32(8) + + +WILDCARD_SOURCE = """\ +module wild_home + implicit none +contains + subroutine one(value, out) + integer, intent(in) :: value + integer, intent(out) :: out + out = value + 1 + end subroutine one + subroutine two(value, out) + integer, intent(in) :: value + integer, intent(out) :: out + out = value + 2 + end subroutine two +end module wild_home +""" + + +def _wildcard_contracts(tmp_path: Path, consumer: str) -> Path: + """Generate contracts, withhold `two` from the home surface, add a consumer.""" + source = tmp_path / "wild.f90" + source.write_text(WILDCARD_SOURCE, encoding="utf-8") + package = tmp_path / "contracts" + subprocess.run( + [sys.executable, "-m", "prik", "generate", "--pyi", str(source), "--out", str(package)], + capture_output=True, + text=True, + check=True, + ) + home = package / "wild_home.pyi" + home.write_text(home.read_text(encoding="utf-8").replace('["one", "two"]', '["one"]'), encoding="utf-8") + package.joinpath("wild_reader.pyi").write_text(consumer, encoding="utf-8") + package.joinpath("__init__.pyi").write_text( + 'from . import wild_home\nfrom . import wild_reader\n\n__all__ = ["wild_home", "wild_reader"]\n', + encoding="utf-8", + ) + return package / "__init__.pyi" + + +def _build_wildcard(entry: Path, tmp_path: Path, name: str): + result = build_pyi_extension( + entry, + input_compiler=_compiler(), + native_fortran_sources=[str(tmp_path / "wild.f90")], + output_dir=tmp_path / name, + output_name=name, + ) + return _import_from_build_dir(result.module_name, result.output_dir) + + +def test_wildcard_import_reads_only_the_surface_its_dependency_publishes(tmp_path: Path): + """A wildcard takes what a contract publishes, not everything it holds. + + The dependency stated its surface, and a name left off it is not part of + what writing `*` asks for. + """ + entry = _wildcard_contracts(tmp_path, "from .wild_home import *\n") + module = _build_wildcard(entry, tmp_path, "wildcard_star") + + assert hasattr(module.wild_home, "one") + assert not hasattr(module.wild_home, "two") + assert hasattr(module.wild_reader, "one") + assert not hasattr(module.wild_reader, "two") + + +def test_explicit_import_reaches_and_can_republish_a_withheld_name(tmp_path: Path): + """A withheld name stays reachable, because a contract may still need it. + + Expressing a declaration or publishing the name again both require asking + for it, which is exactly what naming it in an import does. + """ + entry = _wildcard_contracts(tmp_path, 'from .wild_home import two\n\n__all__ = ["two"]\n') + module = _build_wildcard(entry, tmp_path, "wildcard_named") + + assert not hasattr(module.wild_home, "two") + assert module.wild_reader.two(np.int32(5)) == np.int32(7) diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_loading.py b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_loading.py index ee534f976..bd5ef3be4 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_loading.py +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_loading.py @@ -151,3 +151,63 @@ def test_pyi_python_api_rejects_invalid_projection_before_codegen(tmp_path: Path build_pyi_extension(INVALID_NATIVE_CALL_PYI, native_objects=[native_object], output_dir=tmp_path / "build") assert not list((tmp_path / "build").glob("*_wrapper.*")) + + +STALE_PACKAGE_HOME = ( + "from prik.contracts import Int32\n\ndef calculate(value: Int32) -> Int32: ...\n\n__all__ = [{names}]\n" +) + + +def _stale_package(tmp_path: Path, home: str, reader: str | None = None) -> Path: + package = tmp_path / "pkg" + package.mkdir(parents=True) + package.joinpath("home_mod.pyi").write_text(home, encoding="utf-8") + modules = ["home_mod"] + if reader is not None: + package.joinpath("reader_mod.pyi").write_text(reader, encoding="utf-8") + modules.append("reader_mod") + lines = "".join(f"from . import {name}\n" for name in modules) + stated = ", ".join(f'"{name}"' for name in modules) + package.joinpath("__init__.pyi").write_text(f"{lines}\n__all__ = [{stated}]\n", encoding="utf-8") + return package / "__init__.pyi" + + +def _loaded_modules(entry: Path) -> dict[Path, object]: + """Load one contract package the way a wrapper build loads it.""" + cache = pyi_pipeline._PyiSemanticModuleCache() + paths = tuple(sorted({entry, *_discover_pyi_imports(entry, cache)})) + return dict(zip(paths, cache.paths_to_semantic_modules(paths), strict=True)) + + +def test_all_naming_a_renamed_declaration_is_rejected(tmp_path: Path): + """`__all__` asserts a surface, so a name it states has to exist. + + Renaming a declaration renames what the contract publishes. Dropping the + stale name instead would leave the contract publishing nothing and say so + nowhere, which is far harder to find than a refused build. + """ + entry = _stale_package(tmp_path, STALE_PACKAGE_HOME.format(names='"old_name"')) + + with pytest.raises(ValueError, match=r"__all__ names nothing this contract declares or imports"): + build_pipeline._apply_pyi_python_exports(entry, _loaded_modules(entry)) + + +def test_all_naming_a_stale_imported_alias_is_rejected(tmp_path: Path): + """An imported name that no longer arrives under that alias is stale too.""" + entry = _stale_package( + tmp_path, + STALE_PACKAGE_HOME.format(names='"calculate"'), + 'from .home_mod import calculate as renamed\n\n__all__ = ["calculate"]\n', + ) + + with pytest.raises(ValueError, match=r"__all__ names nothing this contract declares or imports"): + build_pipeline._apply_pyi_python_exports(entry, _loaded_modules(entry)) + + +def test_all_accepts_an_empty_list_and_repeated_names(tmp_path: Path): + """Publishing nothing is a statement; naming one entity twice states it once.""" + entry = _stale_package(tmp_path, STALE_PACKAGE_HOME.format(names="")) + build_pipeline._apply_pyi_python_exports(entry, _loaded_modules(entry)) + + entry = _stale_package(tmp_path / "again", STALE_PACKAGE_HOME.format(names='"calculate", "calculate"')) + build_pipeline._apply_pyi_python_exports(entry, _loaded_modules(entry)) 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 61e276d0b..efb0db28a 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 @@ -12,6 +12,7 @@ opaque_dependency_modules, pyi_text_to_semantic_module as _parse_pyi_text, ) +from prik.semantics import fortran_file_to_semantic_modules from prik.semantics.fortran2ir import fortran_module_to_semantic_module from prik.semantics.models import ( SemanticArgument, @@ -922,3 +923,41 @@ def test_a_published_intrinsic_name_states_no_contract_import(): assert [reexport.origin_module for reexport in module.reexports] == ["iso_fortran_env", "iso_fortran_env"] assert "iso_fortran_env" not in code assert '__all__ = ["rate"]' in code + + +def test_generated_contract_omits_a_republication_no_build_can_expose(): + """A contract states only what publishing can actually reach. + + A module variable holds state that stays live where it is declared, and a + generic is a dispatch surface rather than one object, so neither reaches a + second namespace. A source build publishes neither, and a contract written + from that source claims neither, which keeps the two builds agreeing. + """ + parsed = parse_fortran_source(""" +module state_home +implicit none +integer, save :: counter = 7 +contains +subroutine bump() +counter = counter + 1 +end subroutine bump +end module state_home + +module state_facade +use state_home, only : counter, bump +implicit none +private +public :: counter, bump +end module state_facade +""") + + modules = fortran_file_to_semantic_modules(parsed) + facade = next(module for module in modules if module.name == "state_facade") + stubs = emit_module_stubs(modules, normalize_fortran_public_names=True) + + assert sorted((item.local_name, item.entity_kind) for item in facade.reexports) == [ + ("bump", "procedure"), + ("counter", "variable"), + ] + # The procedure is publishable; the live variable stays where it is declared. + assert stubs["state_facade"].rstrip().endswith('__all__ = ["bump"]') From 63648fa44a11cfa2499d72aee4b989ab9f05074d Mon Sep 17 00:00:00 2001 From: said Date: Wed, 16 Sep 2026 07:51:55 +0100 Subject: [PATCH 29/96] Say that a C export allowlist states the same surface as __all__ The C route already selects a public surface while reading source, and a semantic contract already states one in __all__. Nothing connected them, so --export-symbols read as a private include-exposure knob and a generated contract's __all__ read as something separate PRIK happened to write. Name them as the same statement in two places: the allowlist selects the source-side public surface, and generating a contract records the Python names it publishes in __all__. After that the contract is authoritative -- edit the list, not the allowlist, which a contract build rejects. The two lists live in different naming domains, the file naming native C identifiers and __all__ naming what reaches Python, so the docs say that rather than implying the spellings are interchangeable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 6 +- docs/user/examples/c/libm-wrapper.md | 6 ++ docs/user/faq/index.md | 7 +++ .../c/symbols-headers-and-dependencies.md | 20 ++++++- docs/user/reference/cli-commands.md | 21 +++++-- docs/user/reference/python-api.md | 19 ++++++ examples/c/libm/README.md | 9 ++- prik/cli.py | 9 ++- prik/pipeline/build.py | 8 ++- prik/semantics/c2ir.py | 5 +- .../end_to_end/test_export_symbol_workflow.py | 59 +++++++++++++++++-- .../pipeline/test_c_cli_argument_contract.py | 37 ++++++++++++ 12 files changed, 185 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56489fc8e..83e378580 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,11 @@ release tags add a leading `v` to the package version. PRIK writes what the source publishes -- the module's own public declarations and any imported name a `public` statement names -- and the list is there to be edited: remove a name to stop publishing it, add an imported one to publish - it, or remove the list to publish everything the contract reaches. + it, or remove the list to publish everything the contract reaches. Reading C + source states the same thing through `--export-symbols` / + `build_c_extension(export_symbols=...)`, which selects the source-side public + surface and writes the corresponding Python names into the generated + contract's `__all__`. - A re-exported procedure binds the callable its declaring module exported rather than being wrapped again, so a contract build gives the same object a diff --git a/docs/user/examples/c/libm-wrapper.md b/docs/user/examples/c/libm-wrapper.md index 9c3aeb797..8b186d84e 100644 --- a/docs/user/examples/c/libm-wrapper.md +++ b/docs/user/examples/c/libm-wrapper.md @@ -87,6 +87,12 @@ under [`examples/c/libm/`](../../../../examples/c/libm/). reviewed 60-function public surface. The export allowlist excludes the rest of the platform header and fails if a requested ISO C99 function is missing. +The allowlist names native C functions; the generated contract records the +corresponding Python public names in `__all__`. Review that list together with +the signatures below it. Once you build from the contract, `__all__` is the +statement of what the module publishes, and `--export-symbols` no longer takes +part. + Generate the contract for the active target with: ```bash diff --git a/docs/user/faq/index.md b/docs/user/faq/index.md index 921d4dffd..80bd4ba8f 100644 --- a/docs/user/faq/index.md +++ b/docs/user/faq/index.md @@ -125,6 +125,13 @@ python3 -m prik generate --pyi --language c include/vendor.h \ --out vendor.pyi ``` +`symbols.txt` defines the source-side public function surface. PRIK records the +corresponding Python public names in `vendor.pyi`'s `__all__`. Review and edit +that list alongside the signatures: once you build from the contract, `__all__` +controls what the contract publishes and `--export-symbols` is no longer used. +Adding a name to `__all__` publishes a declaration the contract already +reaches; it cannot conjure one the C sources never declared. + Pass the header's normal `-I`, `-D`, and `--std` options when it needs them. Review `vendor.pyi` before building. Primitive scalar signatures are ready to use; edit pointer parameters when they represent arrays, outputs, or strings. diff --git a/docs/user/guide/c/symbols-headers-and-dependencies.md b/docs/user/guide/c/symbols-headers-and-dependencies.md index 03668ac3e..190f7a95c 100644 --- a/docs/user/guide/c/symbols-headers-and-dependencies.md +++ b/docs/user/guide/c/symbols-headers-and-dependencies.md @@ -171,9 +171,18 @@ python3 -m prik generate --pyi --language c api_probe.h \ --out contracts/api.pyi ``` -The export file selects the semantic API, not linker exports. Selected -functions still need native link inputs and a signature supported by the C -wrapper. See [C include +The name file defines the source-side public function surface, not linker +exports. `generate --pyi` writes that surface into the generated contract's +`__all__`. The two lists live in different naming domains: the file names +native C identifiers, and `__all__` names the Python names the contract +publishes. + +The allowlist chooses the initial contract surface from C source. Once the +`.pyi` exists, `__all__` is the editable authority for what that contract +publishes, and a contract build rejects `--export-symbols`. + +Selected functions still need native link inputs and a signature supported by +the C wrapper. See [C include exposure](../../reference/cli-commands.md#c-include-exposure) for the file format and validation rules. @@ -187,6 +196,11 @@ build = build_c_extension( ) ``` +`export_symbols=` is the Python API equivalent of `--export-symbols`: it selects +the same source-side public function surface for a direct C source build. A +direct build writes no `.pyi`; generating a contract instead represents that +same surface as `__all__`. + ### Inspect a broader C API The parser and contract generator accept more syntax than the supported diff --git a/docs/user/reference/cli-commands.md b/docs/user/reference/cli-commands.md index d569c8d92..942dc3ae8 100644 --- a/docs/user/reference/cli-commands.md +++ b/docs/user/reference/cli-commands.md @@ -389,11 +389,22 @@ C contracts—not whether the native compiler can find an include file. | `--include-exposure {reachable-project,roots-only}` | Exposes reachable project headers by default, or only the root inputs. | | `--public-include PATH_OR_PATTERN` | Exposes declarations from matching included files. Repeat as needed. | | `--private-include PATH_OR_PATTERN` | Hides declarations from matching included files. Repeat as needed. | -| `--export-symbols FILE` | Selects the exact reachable C functions named by FILE and makes those declarations public, including declarations from otherwise-private system headers. | - -`--export-symbols` is a function-only allowlist for commands that produce -semantic IR: source builds, `semantics`, and `generate --pyi`. The UTF-8 file -contains one ASCII C identifier per line; blank lines and text after `#` are ignored. +| `--export-symbols FILE` | Selects the exact reachable C functions named by FILE as the source-side public surface, including declarations from otherwise-private system headers. `generate --pyi` records the corresponding Python public names in the contract's `__all__`. | + +`--export-symbols` is a function-only allowlist for commands that read C +source: source builds, `semantics`, and `generate --pyi`. It defines the +source-side public function surface. When `generate --pyi` writes that surface +as an editable semantic contract, the corresponding Python public names are +written to the contract's `__all__`. + +The two lists live in different naming domains: the file names native C +identifiers, and `__all__` names what the contract publishes to Python. After +generation the contract is authoritative — edit `__all__` to change what it +publishes rather than passing `--export-symbols` again, which a contract build +rejects. + +The UTF-8 file contains one ASCII C identifier per line; blank lines and text +after `#` are ignored. Every listed name must resolve to exactly one reachable function. Empty files, invalid or repeated names, unknown names, names of non-function declarations, and ambiguous declarations fail the command. All declarations not selected by diff --git a/docs/user/reference/python-api.md b/docs/user/reference/python-api.md index 25b0149eb..bb2c509e9 100644 --- a/docs/user/reference/python-api.md +++ b/docs/user/reference/python-api.md @@ -80,6 +80,25 @@ native_math = build.import_module() print(native_math.add(np.float64(3.0), np.float64(2.5))) ``` +Pass `export_symbols` to restrict the build to an exact reviewed set of +reachable C functions. That set is the source-side public surface: it selects +which C declarations are converted, and the matching stub emission records the +corresponding Python public names in the module's `__all__`. + +```python +from prik import build_c_extension + +build = build_c_extension( + "vendor.h", + output_dir="build", + export_symbols=["vendor_open", "vendor_close"], +) +``` + +Unknown names fail the build rather than silently producing a smaller module. +Once you author or generate a semantic `.pyi` contract, that contract's own +`__all__` states the public surface and `export_symbols` no longer applies. + For an authored C semantic contract, use `build_pyi_extension` with `native_language="c"` and `native_c_sources=[...]`. [C Pointers, Arrays, and Strings](../guide/c/pointers-arrays-and-strings.md#author-a-contract-for-pointers-and-arrays) diff --git a/examples/c/libm/README.md b/examples/c/libm/README.md index 3874f3345..f875e437d 100644 --- a/examples/c/libm/README.md +++ b/examples/c/libm/README.md @@ -15,7 +15,9 @@ caller-owned output arrays, see [TA-Lib](../ta_lib/README.md). Its layout mirrors the other real-library examples: - `libm_probe.h` includes the target toolchain's own ``. -- `iso_c99_routines.txt` is the reviewed 60-function allowlist. +- `iso_c99_routines.txt` is the reviewed 60-function allowlist: it selects the + source-side public function surface, and the generated contract records the + corresponding Python public names in `__all__`. - `build_prik.sh` generates the target contract and builds the extension. - `build_all.sh` exposes the built module on `PYTHONPATH`. - `routine_inventory.py` groups every public function and names its test. @@ -98,6 +100,11 @@ if ! python3 -m prik --language c "$LIBM_BUILD_ROOT/prik/contract/libm_api.pyi" fi ``` +The first command generates `libm_api.pyi`, which lists the allowlisted +functions in `__all__`; the second builds from that contract. From then on +`__all__` is what publishes the API, and the allowlist is read only when the +contract is regenerated from C source. + The public signature uses target-sized NumPy contract types. Exact native C identities appear only at the native boundary. For example, an LP64 target may generate: diff --git a/prik/cli.py b/prik/cli.py index 9f23ca639..f6f4758c2 100644 --- a/prik/cli.py +++ b/prik/cli.py @@ -1037,8 +1037,8 @@ def _validate_pyi_wrapper_options(args: argparse.Namespace, parser: argparse.Arg ) if getattr(args, "export_symbols", None): parser.error( - "--export-symbols selects declarations while reading C source; a semantic .pyi contract " - "already states its public functions" + "--export-symbols selects the public surface while reading C source; a semantic .pyi " + "contract already states its public surface in __all__" ) if not getattr(args, "external_native_implementation", False) and not ( getattr(args, "native_fortran_sources", None) @@ -2329,7 +2329,10 @@ def _add_semantic_interpretation_options( group.add_argument( "--export-symbols", metavar="FILE", - help="Select exact reachable C functions from a UTF-8 name file; C semantic commands only", + help=( + "Select exact reachable C functions from a UTF-8 name file as the source-side " + "public surface; generate --pyi records the corresponding Python names in __all__" + ), ) diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index 664d0125f..a1b54dae4 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -3933,8 +3933,12 @@ def build_c_extension( unsupported operations raise a documented completed-policy diagnostic before planning, generated files, or compiler commands. A selected genuine identifier collision may use a separate C forwarder translation unit. - ``export_symbols`` restricts semantic conversion to those exact reachable - C functions and can explicitly select declarations from included headers. + ``export_symbols`` names the source-side public surface: semantic conversion + keeps exactly those reachable C functions, and can explicitly select + declarations from included headers. It is the C-source equivalent of the + ``__all__`` a semantic ``.pyi`` contract states for itself; emitted stubs + record the corresponding Python public names there. Unknown names are + rejected rather than silently narrowing the module. ``compile_input_sources`` controls whether the parsed C sources are also compiled. ``native_c_sources`` adds separately compiled C inputs, while explicit Fortran inputs are supported only as ordinary link dependencies. diff --git a/prik/semantics/c2ir.py b/prik/semantics/c2ir.py index 0af9d2759..f8071d322 100644 --- a/prik/semantics/c2ir.py +++ b/prik/semantics/c2ir.py @@ -2045,11 +2045,14 @@ def select_c_export_functions( ) -> list[SemanticModule]: """Restrict C semantic IR to an exact, fail-closed function allowlist. + ``symbols`` names native C functions and states the source-side public + surface, the way a semantic ``.pyi`` contract states its own ``__all__``. The selection happens after ordinary include exposure has recorded source provenance and before policy completion. Selected functions receive one explicit-export marker so a declaration from an included system header is intentionally treated as part of the wrapped translation unit. Every - other declaration category is removed from the selected semantic surface. + other declaration category is removed from the selected semantic surface, + so the emitted stub publishes exactly the corresponding Python names. """ selected_modules = list(modules) requested = _validated_c_export_symbols(symbols) diff --git a/tests/c/functions/end_to_end/test_export_symbol_workflow.py b/tests/c/functions/end_to_end/test_export_symbol_workflow.py index 809b70f62..b8fb6931f 100644 --- a/tests/c/functions/end_to_end/test_export_symbol_workflow.py +++ b/tests/c/functions/end_to_end/test_export_symbol_workflow.py @@ -1,5 +1,6 @@ """Compiled and CLI evidence for selecting functions from a private C include.""" +import ast import shutil import subprocess import sys @@ -33,12 +34,17 @@ def _write_private_include_project(tmp_path: Path) -> tuple[Path, Path, Path]: return header, probe, implementation -def test_generate_pyi_selects_one_function_from_a_private_include(tmp_path: Path): - _header, probe, _implementation = _write_private_include_project(tmp_path) - exports = tmp_path / "exports.txt" - exports.write_text("# reviewed public surface\nincrement\n", encoding="utf-8") - contract = tmp_path / "api.pyi" +def _stated_exports(contract: Path) -> list[str]: + """Return the ``__all__`` a generated contract states about itself.""" + module = ast.parse(contract.read_text(encoding="utf-8"), filename=str(contract)) + for statement in module.body: + targets = getattr(statement, "targets", []) + if any(isinstance(target, ast.Name) and target.id == "__all__" for target in targets): + return [ast.literal_eval(element) for element in statement.value.elts] + raise AssertionError(f"{contract} states no __all__") + +def _generate_contract(probe: Path, exports: Path, contract: Path) -> None: subprocess.run( [ sys.executable, @@ -64,10 +70,53 @@ def test_generate_pyi_selects_one_function_from_a_private_include(tmp_path: Path check=True, ) + +def test_generate_pyi_selects_one_function_from_a_private_include(tmp_path: Path): + _header, probe, _implementation = _write_private_include_project(tmp_path) + exports = tmp_path / "exports.txt" + exports.write_text("# reviewed public surface\nincrement\n", encoding="utf-8") + contract = tmp_path / "api.pyi" + + _generate_contract(probe, exports, contract) + text = contract.read_text(encoding="utf-8") assert "def increment(" in text assert "omitted" not in text assert "private_state" not in text + assert _stated_exports(contract) == ["increment"] + + +def test_generated_all_states_the_selected_declarations_not_the_allowlist_lines(tmp_path: Path): + """The allowlist selects declarations; __all__ states the Python names they publish.""" + header = tmp_path / "reviewed_api.h" + header.write_text("int zulu(int __v);\nint alpha(int __v);\nint omitted(int __v);\n", encoding="utf-8") + probe = tmp_path / "probe.c" + probe.write_text('#include "reviewed_api.h"\n', encoding="utf-8") + exports = tmp_path / "exports.txt" + exports.write_text("# reviewed public surface\n\nalpha\n\nzulu\n", encoding="utf-8") + contract = tmp_path / "api.pyi" + + _generate_contract(probe, exports, contract) + + # Declaration order, not allowlist order: the list follows the declarations + # the selection kept, so comments, blank lines, and the file's own ordering + # never reach it. + assert _stated_exports(contract) == ["zulu", "alpha"] + assert "omitted" not in contract.read_text(encoding="utf-8") + + +def test_a_repeated_allowlist_name_is_rejected_rather_than_stated_twice(tmp_path: Path): + """A stated surface names each declaration once, so a repeat is a request error.""" + _header, probe, _implementation = _write_private_include_project(tmp_path) + exports = tmp_path / "exports.txt" + exports.write_text("increment\nincrement\n", encoding="utf-8") + contract = tmp_path / "api.pyi" + + with pytest.raises(subprocess.CalledProcessError) as exc_info: + _generate_contract(probe, exports, contract) + + assert "Repeated C function name in --export-symbols" in exc_info.value.stderr + assert not contract.exists() def test_source_build_reuses_selection_with_positional_and_collision_policies(tmp_path: Path): diff --git a/tests/c/infrastructure/cli/pipeline/test_c_cli_argument_contract.py b/tests/c/infrastructure/cli/pipeline/test_c_cli_argument_contract.py index 228f7d631..d0c26abec 100644 --- a/tests/c/infrastructure/cli/pipeline/test_c_cli_argument_contract.py +++ b/tests/c/infrastructure/cli/pipeline/test_c_cli_argument_contract.py @@ -115,3 +115,40 @@ def error(self, message): assert str(requested_error.value) == ( f"C input {c_header} is incompatible with --language fortran; pass --language c. Use --help for examples." ) + + +def test_pyi_wrapper_build_rejects_export_symbols(tmp_path: Path, capsys): + """A contract already states its public surface, so the C allowlist has nothing to select.""" + contract = tmp_path / "api.pyi" + contract.write_text("from prik.contracts import Int\n", encoding="utf-8") + implementation = tmp_path / "api.c" + implementation.write_text("int increment(int value) { return value + 1; }\n", encoding="utf-8") + exports = tmp_path / "exports.txt" + exports.write_text("increment\n", encoding="utf-8") + + with pytest.raises(SystemExit) as exc_info: + prik_cli.main( + [ + str(contract), + "--native-c-sources", + str(implementation), + "--export-symbols", + str(exports), + ] + ) + + assert exc_info.value.code == 2 + message = capsys.readouterr().err + assert "--export-symbols selects the public surface" in message + assert "__all__" in message + + +def test_export_symbols_help_names_the_public_surface_it_selects(): + """The option and a contract's __all__ state the same thing, so the help says so.""" + build_help = prik_cli._build_parser(["input.h", "--language", "c", "--help"]).format_help() + generate_help = prik_cli._generate_parser(["--help"]).format_help() + + for help_text in (build_help, generate_help): + assert "--export-symbols" in help_text + assert "public surface" in help_text + assert "__all__" in help_text From bda1c03e5acb434880a04e21c2020dd793bad7be Mon Sep 17 00:00:00 2001 From: said Date: Wed, 16 Sep 2026 08:01:08 +0100 Subject: [PATCH 30/96] Say where a direct C build writes its stubs The export-symbols guide claimed a direct build writes no .pyi. It writes one per module into a contracts/ directory beside the extension, stating the selected surface as __all__ the same way generate --pyi does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- docs/user/guide/c/symbols-headers-and-dependencies.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/user/guide/c/symbols-headers-and-dependencies.md b/docs/user/guide/c/symbols-headers-and-dependencies.md index 190f7a95c..27fffb0ea 100644 --- a/docs/user/guide/c/symbols-headers-and-dependencies.md +++ b/docs/user/guide/c/symbols-headers-and-dependencies.md @@ -197,9 +197,10 @@ build = build_c_extension( ``` `export_symbols=` is the Python API equivalent of `--export-symbols`: it selects -the same source-side public function surface for a direct C source build. A -direct build writes no `.pyi`; generating a contract instead represents that -same surface as `__all__`. +the same source-side public function surface for a direct C source build. The +build writes type stubs for the extension into a `contracts/` directory beside +it, and those stubs state the selected surface as `__all__` the same way +`generate --pyi` does. ### Inspect a broader C API From 80aef84ea5d713b7ffcdc5ff82e1ecfcd750f9ad Mon Sep 17 00:00:00 2001 From: said Date: Wed, 16 Sep 2026 09:10:09 +0100 Subject: [PATCH 31/96] Decide a public name once, and let every stage write that one Naming lived in two places. prik.naming decided it for the build, and the .pyi printer decided it again for the contract -- its own NamingPolicy, its own collision counter, and only for Fortran. So a build and the contract describing it could disagree: a C function named for a Python keyword built as lambda_ while its contract said "def lambda(", which does not parse at all, and a Fortran build wrote "def SCALE_VALUE(" beside a module exposing scale_value. The printer now applies the shared rule instead of a Fortran-shaped copy of it, and every stub emission -- generate --pyi for either language, and the contract a source build leaves beside its artifacts -- states the names that build publishes. Where the two spellings differ the contract records the source one with @bind, the way it already did for Fortran. Making the rule shared exposed that it was Fortran's rule. Folding case is right there, because Fortran writes one declaration under many spellings and none of them is its own. C names each declaration exactly, so folding lost the name -- BarBaz reached Python as barbaz -- and invented collisions the source does not have: Foo and foo are two functions, and they arrived as foo and foo_2 with nothing to say which was which. preserves_source_case states which languages have a spelling to keep, and the naming owner reads it. A generated symbol is still shared with Fortran, which folds case, so two Python names a case-sensitive source keeps apart can reach one stem that qualifying by namespace cannot separate. Planning numbers those. This changes the published names of existing C wrappers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 20 +++ docs/user/reference/pyi-format.md | 16 ++- prik/cli.py | 9 +- prik/naming/__init__.py | 2 + prik/naming/policy.py | 34 +++-- prik/pipeline/build.py | 4 +- prik/pipeline/pyi.py | 6 +- prik/planning/planner.py | 27 +++- prik/policy/construction.py | 7 +- prik/policy/exports.py | 10 +- prik/printers/pyi.py | 58 +++++---- .../end_to_end/test_public_name_contract.py | 119 ++++++++++++++++++ .../test_generated_generic_contracts.py | 2 +- .../infrastructure/codegen/test_planner.py | 21 ++++ .../infrastructure/naming/test_policy.py | 32 ++++- .../test_pyi_printer_imports_and_packages.py | 32 ++--- .../pipeline/test_types_and_declarations.py | 2 +- 17 files changed, 328 insertions(+), 73 deletions(-) create mode 100644 tests/c/functions/end_to_end/test_public_name_contract.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 83e378580..85a5d8cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ release tags add a leading `v` to the package version. ## Unreleased +- A wrapper's Python names are decided once, by `prik.naming`, and every stage + that writes a name implements that decision. The `.pyi` printer re-derived + them instead, and only for Fortran, so a build and the contract describing it + could disagree: a C function named for a Python keyword built as `lambda_` + while its contract said `def lambda(`, which is not Python at all. A contract + now names exactly what the build beside it publishes, and records the source + spelling with `@bind` wherever the two differ. + +- A C declaration keeps the case it is written in. Folding it is a Fortran rule, + correct there because Fortran writes one declaration many ways and none of + the spellings is its own. C names each declaration exactly, so folding both + lost that name -- `BarBaz` reached Python as `barbaz` -- and invented + collisions the source does not have: `Foo` and `foo` are two functions, and + they arrived as `foo` and `foo_2` with nothing to say which was which. This + changes the published names of existing C wrappers. + +- The contract a source build writes beside its artifacts states published + Python names. It stated raw source spellings, so a Fortran build wrote + `def SCALE_VALUE(` next to a module exposing `scale_value`. + - A re-exported declaration is owned by the contract declaring it, whichever order an entry contract imports from. The namespace encountered first owned it, so a facade listed before the module it reads from took ownership of a diff --git a/docs/user/reference/pyi-format.md b/docs/user/reference/pyi-format.md index c9c9a9d02..a65ce054c 100644 --- a/docs/user/reference/pyi-format.md +++ b/docs/user/reference/pyi-format.md @@ -1009,12 +1009,16 @@ def consume(class_: Annotated[Int32, SourceName("class")]) -> None: ... Python export name. `SourceName(...)` preserves a native data or argument name. These are separate operations. -When PRIK generates a Fortran contract, it lowercases Fortran identifiers, -adds a trailing underscore to Python keywords, normalizes other invalid Python -identifiers, and gives remaining collisions deterministic numeric suffixes. -The same policy covers module members, classes, methods, fields, and argument -names. `--strict-wrapper-names` rejects a generated name that would need any of -these fixes. +When PRIK generates a contract it adds a trailing underscore to Python +keywords, normalizes other invalid Python identifiers, and gives remaining +collisions deterministic numeric suffixes. A Fortran identifier is lowercased +as well, because Fortran writes one declaration under many spellings and none +of them is the declaration's own. A C identifier keeps its case: C names each +declaration exactly, so `BarBaz` stays `BarBaz`, and `Foo` and `foo` stay two +functions. The same policy covers module members, classes, methods, fields, and +argument names, and it decides the names a build publishes and the names the +contract describing that build states. `--strict-wrapper-names` rejects a +generated name that would need any of these fixes. Fortran `bind(C, name=...)` changes the native symbol, not the Python name. In an edited contract, `@bind("native_name")` records that native-name distinction; diff --git a/prik/cli.py b/prik/cli.py index f6f4758c2..edafceb7d 100644 --- a/prik/cli.py +++ b/prik/cli.py @@ -670,8 +670,9 @@ def _semantic_payload_for_converted_files(converted_files) -> dict[str, dict]: if _is_c_semantic_file(modules): # A generated C starter contract preserves raw source facts, even # for a form that the direct-only wrapper policy will later block. - # ``--pyi`` is contract extraction, not wrapper planning. - module_stubs = {module.name: emit_module(module).strip() for module in modules} + # ``--pyi`` is contract extraction, not wrapper planning. The names + # are still C's, so they are written the way a build publishes them. + module_stubs = {module.name: emit_module(module, normalize_public_names=True).strip() for module in modules} out[str(p)] = { "semantic_modules": [asdict(module) for module in modules], "pyi": "\n\n".join(module_stubs.values()).strip(), @@ -710,7 +711,7 @@ def _fortran_contract_payload(path: Path, modules, available_modules) -> dict[st emit_module_stubs( native_modules, available_modules=available_modules, - normalize_fortran_public_names=True, + normalize_public_names=True, ) if native_modules else {} @@ -722,7 +723,7 @@ def _fortran_contract_payload(path: Path, modules, available_modules) -> dict[st external_stubs = emit_module_stubs( [module], available_modules=available_modules, - normalize_fortran_public_names=True, + normalize_public_names=True, ) external_text.append(external_stubs.pop(module.name)) for name, text in external_stubs.items(): diff --git a/prik/naming/__init__.py b/prik/naming/__init__.py index 6f31439ef..ccf9176c0 100644 --- a/prik/naming/__init__.py +++ b/prik/naming/__init__.py @@ -17,6 +17,7 @@ PublicNameRecord, generated_symbol_rules, normalize_public_name, + preserves_source_case, ) __all__ = ( @@ -32,6 +33,7 @@ "bridge_source_name", "generated_symbol_rules", "normalize_public_name", + "preserves_source_case", "stub_identifier", "wrapper_header_name", ) diff --git a/prik/naming/policy.py b/prik/naming/policy.py index 5657214b5..55611d44b 100644 --- a/prik/naming/policy.py +++ b/prik/naming/policy.py @@ -10,6 +10,8 @@ from prik.utilities.strings import create_incremented_string _NON_IDENTIFIER = re.compile(r"[^0-9A-Za-z_]") +# Only a case-insensitive source language has no spelling of its own to keep. +_CASE_INSENSITIVE_SOURCE_LANGUAGES = frozenset({"fortran"}) _SYMBOL_CONTEXTS = frozenset({"module", "function", "class", "variable", "wrapper"}) _PARENT_CONTEXTS = frozenset({"module", "function", "class", "loop", "program"}) @@ -53,23 +55,41 @@ def has_clash(self, name: object, symbols: set[object]) -> bool: return folded in self.keywords or any(folded == str(symbol).casefold() for symbol in symbols) -def normalize_public_name(raw_name: object) -> NormalizedPublicName: - """Convert a source spelling into a valid, lower-case Python identifier.""" +def preserves_source_case(source_language: object) -> bool: + """Return whether a source language's own casing is part of a name. + + A case-insensitive language writes the same declaration many ways, so no + spelling is the declaration's own and one canonical lower-case form is the + Python name. Every other language distinguishes two spellings as two + declarations, so the source casing is the name and folding it would both + lose the identity and invent collisions the source does not have. + """ + return str(source_language or "").casefold() not in _CASE_INSENSITIVE_SOURCE_LANGUAGES + + +def normalize_public_name(raw_name: object, *, preserve_case: bool = False) -> NormalizedPublicName: + """Convert a source spelling into a valid Python identifier. + + The result is lower-cased unless ``preserve_case`` says the source casing + is part of the name; see ``preserves_source_case``. Either way the spelling + is only adjusted where Python cannot accept it. + """ source = str(raw_name).strip() - folded = source.casefold() - normalized = _NON_IDENTIFIER.sub("_", folded) or "_" + candidate = source if preserve_case else source.casefold() + normalized = _NON_IDENTIFIER.sub("_", candidate) or "_" if not (normalized[0].isalpha() or normalized[0] == "_"): normalized = f"_{normalized}" if keyword.iskeyword(normalized): normalized = f"{normalized}_" - return NormalizedPublicName(normalized, needs_fix=normalized != folded) + return NormalizedPublicName(normalized, needs_fix=normalized != candidate) class NamingPolicy: """Allocate Python exports and language-safe generated symbols.""" - def __init__(self, *, strict_public_names: bool = False): + def __init__(self, *, strict_public_names: bool = False, preserve_case: bool = False): self.strict_public_names = strict_public_names + self.preserve_case = preserve_case self._public_names: dict[tuple[str, ...], dict[str, PublicNameRecord]] = {} def reserve_public_name( @@ -81,7 +101,7 @@ def reserve_public_name( owner: object | None = None, ) -> str: """Reserve one public Python name within its namespace.""" - normalized = normalize_public_name(raw_name) + normalized = normalize_public_name(raw_name, preserve_case=self.preserve_case) raw_text = str(raw_name) namespace_key = tuple(str(part) for part in namespace) namespace_text = ".".join(namespace_key) or "" diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index a1b54dae4..79a19198a 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -845,10 +845,12 @@ def _write_build_contract_package( reshaping the Python surface never needs a separate `generate --pyi` run. The package lives in its own directory inside the build output so its ``__init__.pyi`` cannot make the build directory look like a Python package. + Only a source build writes one, so the declarations are named in Fortran or + C and the contract states the Python names this build just published. """ if not source_modules: return () - stubs = emit_module_stubs(source_modules) + stubs = emit_module_stubs(source_modules, normalize_public_names=True) package_dir = output_dir / BUILD_CONTRACT_DIRECTORY_NAME package_dir.mkdir(parents=True, exist_ok=True) written = [] diff --git a/prik/pipeline/pyi.py b/prik/pipeline/pyi.py index 44a151e80..bde2ab70d 100644 --- a/prik/pipeline/pyi.py +++ b/prik/pipeline/pyi.py @@ -104,7 +104,7 @@ def emit_module_stubs( modules: SemanticModule | Iterable[SemanticModule], *, available_modules: Iterable[SemanticModule] | None = None, - normalize_fortran_public_names: bool = False, + normalize_public_names: bool = False, ) -> dict[str, str]: """Complete and render semantic modules plus opaque dependencies. @@ -148,14 +148,14 @@ def emit_module_stubs( } # What a contract publishes a name under is settled by rendering it, so # every module is named once before any of them writes an import. - naming_printer = PyiPrinter(normalize_fortran_public_names=normalize_fortran_public_names) + naming_printer = PyiPrinter(normalize_public_names=normalize_public_names) published_names_by_module = { module_name: naming_printer.published_names(module) for module_name, module in emitted_modules.items() } return { module_name: emit_module( module, - normalize_fortran_public_names=normalize_fortran_public_names, + normalize_public_names=normalize_public_names, declared_prototype_names=declared_prototype_names, published_names_by_module=published_names_by_module, ).strip() diff --git a/prik/planning/planner.py b/prik/planning/planner.py index bd4d5326c..aeee2118e 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -72,7 +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.naming.policy import normalize_public_name, preserves_source_case from prik.policy.exports import PythonExportPolicy from prik.policy.ownership import AssignmentMode, NativeBarrierAction, SetterAction from prik.planning.models import ( @@ -555,6 +555,7 @@ def _aliases_by_namespace(self, module: models.SemanticModule) -> dict[tuple[str knows which name the declaring namespace actually bound. """ grouped = defaultdict(list) + preserve_case = preserves_source_case(module.origin.source_language) for reexport in module.reexports: if reexport.entity_kind not in _ALIASABLE_REEXPORT_KINDS: continue @@ -564,7 +565,7 @@ def _aliases_by_namespace(self, module: models.SemanticModule) -> dict[tuple[str continue grouped[tuple(part.casefold() for part in reexport.module.split(".") if part)].append( NamespaceAliasPlan( - python_name=normalize_public_name(reexport.local_name).name, + python_name=normalize_public_name(reexport.local_name, preserve_case=preserve_case).name, source_namespace=source_namespace, source_name=source_name, ) @@ -1152,9 +1153,31 @@ def _complete_generated_symbols( for namespace, item in entries: if counts[item.symbol_name.casefold()] > 1: item.symbol_name = self._symbol_name(namespace, item.symbol_name) + self._separate_folded_generated_symbols(entries) self._qualify_variable_bridge_collisions(functions, variables) self._complete_entrypoint_symbols(functions) + @staticmethod + def _separate_folded_generated_symbols(entries: tuple[tuple[tuple[str, ...], object], ...]) -> None: + """Separate stems that only a case-sensitive source keeps apart. + + A generated symbol is shared with Fortran, which folds case, so two + declarations a case-sensitive language distinguishes by spelling alone + reach one stem that qualifying by namespace cannot separate. They + publish different Python names, so the stems are numbered in plan order. + """ + taken: set[str] = set() + for _namespace, item in entries: + stem = item.symbol_name + if stem.casefold() not in taken: + taken.add(stem.casefold()) + continue + suffix = 2 + while f"{stem}_{suffix}".casefold() in taken: + suffix += 1 + item.symbol_name = f"{stem}_{suffix}" + taken.add(item.symbol_name.casefold()) + @staticmethod def _complete_entrypoint_symbols( functions: dict[tuple[str, ...], list[FunctionPlan]], diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 4ef4faf4b..978fc39b8 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -20,7 +20,7 @@ from immutabledict import immutabledict from prik.contracts import NATIVE_C_SCALAR_IDENTITIES -from prik.naming import NamingPolicy +from prik.naming import NamingPolicy, preserves_source_case from prik.semantics import models from prik.semantics.metadata import ( ADDRESS_ROLE_METADATA, @@ -482,7 +482,10 @@ def build_class_surface_policy( strict_wrapper_names: bool = False, ) -> ClassSurfacePolicy: """Complete constructor, method, inheritance, and registration decisions.""" - naming = NamingPolicy(strict_public_names=strict_wrapper_names) + naming = NamingPolicy( + strict_public_names=strict_wrapper_names, + preserve_case=preserves_source_case(semantic_class.origin.source_language), + ) fields = _python_named_class_fields(derived.fields, naming, owner_path) named_derived = replace(derived, fields=fields) methods = _python_named_class_methods(semantic_class, naming, owner_path) diff --git a/prik/policy/exports.py b/prik/policy/exports.py index 62c3808d9..6ed134a57 100644 --- a/prik/policy/exports.py +++ b/prik/policy/exports.py @@ -15,7 +15,7 @@ from dataclasses import dataclass -from prik.naming import NamingPolicy, normalize_public_name +from prik.naming import NamingPolicy, normalize_public_name, preserves_source_case from prik.semantics import models @@ -33,7 +33,10 @@ def complete_python_export_policy( strict_wrapper_names: bool = False, ) -> None: """Resolve every public export name within its owning Python namespace.""" - naming = NamingPolicy(strict_public_names=strict_wrapper_names) + naming = NamingPolicy( + strict_public_names=strict_wrapper_names, + preserve_case=preserves_source_case(module.origin.source_language), + ) for owner in _module_export_owners(module): if getattr(owner, "visibility", "public") == "private": continue @@ -107,7 +110,8 @@ def completed_python_exports( ) ) if not exports and getattr(owner, "visibility", "public") != "private": - exports.append(PythonExportPolicy((), normalize_public_name(default_name).name)) + preserve_case = preserves_source_case(owner.origin.source_language) + exports.append(PythonExportPolicy((), normalize_public_name(default_name, preserve_case=preserve_case).name)) return tuple(dict.fromkeys(exports)) diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index 2aa42ae98..819dccc17 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -19,7 +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.naming.policy import normalize_public_name, preserves_source_case from prik.semantics.scalar_types import SEMANTIC_SCALAR_TYPE_NAMES from prik.semantics.ownership_metadata import ( OWNERSHIP_POLICY_METADATA, @@ -88,7 +88,7 @@ class _PyiEmissionContext: """Own all state accumulated while rendering one semantic node tree.""" - normalize_fortran_public_names: bool + normalize_public_names: bool default_array_order: str | None = None semantic_class_names: frozenset[str] = frozenset() contract_aliases: dict[str, str] = field(default_factory=dict) @@ -138,6 +138,10 @@ def public_name(self, raw_name: str, *, category: str, owner: object) -> str: self.published_names.setdefault(str(raw_name).casefold(), public_name) return public_name + def normalized(self, raw_name: object) -> str: + """Return one name under this emission's naming rule, reserving nothing.""" + return normalize_public_name(raw_name, preserve_case=self.naming_policy.preserve_case).name + def contract_import(self) -> str: """Return the direct import for contract symbols used by this emission.""" if not self.contract_imports: @@ -179,21 +183,23 @@ class PyiPrinter(ClassVisitor): def __init__( self, *, - normalize_fortran_public_names: bool = False, + normalize_public_names: bool = False, declared_prototype_names: Iterable[tuple[str, str]] = (), published_names_by_module: dict[str, dict[str, str]] | None = None, ): """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 + Set normalize_public_names when emitting a contract extracted from + native source, whose declarations are named in that language rather + than in Python. A contract read back from .pyi is already named in + Python and keeps every spelling verbatim. Pass 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._normalize_public_names = normalize_public_names self._declared_prototype_names = { (str(module).casefold(), str(name).casefold()): str(name) for module, name in declared_prototype_names } @@ -233,10 +239,14 @@ def _emission_context(self, node) -> _PyiEmissionContext: """Build isolated state for one public emission call.""" if not isinstance(node, SemanticModule): return _PyiEmissionContext( - normalize_fortran_public_names=self._normalize_fortran_public_names, + normalize_public_names=self._normalize_public_names, ) + # Naming is decided by prik.naming for every stage; the emission only + # tells it which language the declarations were written in. + naming_policy = NamingPolicy(preserve_case=preserves_source_case(node.origin.source_language)) return _PyiEmissionContext( - normalize_fortran_public_names=self._normalize_fortran_public_names, + normalize_public_names=self._normalize_public_names, + naming_policy=naming_policy, default_array_order=self._native_default_array_order(node.origin.source_language), semantic_class_names=frozenset( str(cls.name) @@ -429,13 +439,13 @@ def _overload_target_name(candidate: SemanticFunction, context: _PyiEmissionCont 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": + if not context.normalize_public_names: return target # The specific was named while this same contract was rendered, and a # collision may have moved that name aside, so the naming it settled on # is what the target has to state. published = context.published_names.get(target.casefold()) - return published or normalize_public_name(target).name + return published or context.normalized(target) def _visit_ProcedureOverloadSet( self, @@ -1592,7 +1602,7 @@ def _append_imports( self._emit_import( imp, native_source=not module.metadata.get(PYI_LOADED_METADATA), - public_names=context.normalize_fortran_public_names, + public_names=context.normalize_public_names, verbatim_names=verbatim, published_names_by_module=self._published_names_by_module, ) @@ -2303,11 +2313,7 @@ def _callable_name( owner: object | None = None, ) -> str: """Return the Python-visible callable name to write in the contract.""" - if ( - not context.normalize_fortran_public_names - or func.name.startswith("__") - or func.origin.source_language != "fortran" - ): + if not context.normalize_public_names or func.name.startswith("__"): return func.name return context.public_name( func.name, @@ -2321,7 +2327,7 @@ def _data_member_name( context: _PyiEmissionContext, ) -> str: """Return the Python-visible class data-member name.""" - if not context.normalize_fortran_public_names: + if not context.normalize_public_names: return variable.name return context.public_name(variable.name, category="field", owner=variable) @@ -2331,7 +2337,7 @@ def _module_variable_name( context: _PyiEmissionContext, ) -> str: """Return the Python-visible module variable name.""" - if not context.normalize_fortran_public_names: + if not context.normalize_public_names: return variable.name return context.public_name(variable.name, category="variable", owner=variable) @@ -2891,22 +2897,22 @@ def _parameter_target(name: str) -> str: def emit_module( module: SemanticModule, *, - normalize_fortran_public_names: bool = False, + normalize_public_names: bool = False, declared_prototype_names: Iterable[tuple[str, str]] = (), published_names_by_module: dict[str, dict[str, str]] | None = None, ) -> 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, declared_prototype_names to name the prototypes the modules - rendered alongside this one declare, and published_names_by_module to state - the spelling each of those modules published its names under. Every path - creates a fresh module emission context. + normalize_public_names when the module is named in its own source language + rather than in Python, declared_prototype_names to name the prototypes the + modules rendered alongside this one declare, and published_names_by_module + to state the spelling each of those modules published its names under. + Every path creates a fresh module emission context. """ - if normalize_fortran_public_names or declared_prototype_names or published_names_by_module: + if normalize_public_names or declared_prototype_names or published_names_by_module: return PyiPrinter( - normalize_fortran_public_names=normalize_fortran_public_names, + normalize_public_names=normalize_public_names, declared_prototype_names=declared_prototype_names, published_names_by_module=published_names_by_module, ).emit(module) diff --git a/tests/c/functions/end_to_end/test_public_name_contract.py b/tests/c/functions/end_to_end/test_public_name_contract.py new file mode 100644 index 000000000..3ce4dccd9 --- /dev/null +++ b/tests/c/functions/end_to_end/test_public_name_contract.py @@ -0,0 +1,119 @@ +"""The contract a C build writes names exactly what that build publishes.""" + +import ast +import shutil +from pathlib import Path + +import numpy as np +import pytest + +from prik import build_c_extension +from prik.pipeline.build import BUILD_CONTRACT_DIRECTORY_NAME +from prik.preprocessing import PreprocessingConfig +from tests.c._support.runtime import sole_native_module + + +pytestmark = pytest.mark.skipif(shutil.which("cc") is None, reason="requires a C compiler") + + +def _build(tmp_path: Path, name: str, header_text: str, implementation_text: str, symbols: list[str]): + """Build one C extension from a private header and return it with its stub.""" + header = tmp_path / "api.h" + header.write_text(header_text, encoding="utf-8") + probe = tmp_path / "probe.c" + probe.write_text('#include "api.h"\n', encoding="utf-8") + implementation = tmp_path / "implementation.c" + implementation.write_text(f'#include "api.h"\n{implementation_text}', encoding="utf-8") + output_dir = tmp_path / "build" + + result = build_c_extension( + probe, + output_dir=output_dir, + output_name=name, + input_c_compiler=shutil.which("cc") or "cc", + preprocessing=PreprocessingConfig( + mode="compiler", + compiler=shutil.which("cc") or "cc", + include_exposure="roots-only", + ), + export_symbols=symbols, + native_c_sources=[implementation], + ) + contract = output_dir / BUILD_CONTRACT_DIRECTORY_NAME / "probe.pyi" + return sole_native_module(result.import_module()), contract + + +def _stated_exports(contract: Path) -> list[str]: + """Return the ``__all__`` a generated contract states about itself.""" + module = ast.parse(contract.read_text(encoding="utf-8"), filename=str(contract)) + for statement in module.body: + targets = getattr(statement, "targets", []) + if any(isinstance(target, ast.Name) and target.id == "__all__" for target in targets): + return [ast.literal_eval(element) for element in statement.value.elts] + raise AssertionError(f"{contract} states no __all__") + + +def test_a_contract_names_what_its_own_build_published(tmp_path: Path): + """One naming decision reaches both, so the stub is readable and accurate.""" + module, contract = _build( + tmp_path, + "keyword_api", + "int lambda(int value);\nint lambda_(int value);\nint ordinary(int value);\n", + "int lambda(int v) { return v + 1; }\n" + "int lambda_(int v) { return v + 2; }\n" + "int ordinary(int v) { return v + 3; }\n", + ["lambda", "lambda_", "ordinary"], + ) + + published = {name for name in dir(module) if not name.startswith("_")} + assert published == set(_stated_exports(contract)) + # A name Python cannot bind is moved aside once, for the module and the + # contract alike, and the C spelling is recorded rather than lost. + assert published == {"lambda_", "lambda__2", "ordinary"} + text = contract.read_text(encoding="utf-8") + assert '@bind("lambda")' in text + assert '@bind("lambda_")' in text + + +def test_a_generated_contract_is_readable_python(tmp_path: Path): + """A contract exists to be re-read and edited, so it has to parse.""" + _module, contract = _build( + tmp_path, + "readable_api", + "int lambda(int value);\n", + "int lambda(int v) { return v + 1; }\n", + ["lambda"], + ) + + ast.parse(contract.read_text(encoding="utf-8"), filename=str(contract)) + + +def test_c_declarations_that_differ_only_in_case_stay_apart(tmp_path: Path): + """C spells its declarations exactly, so two spellings are two functions.""" + module, contract = _build( + tmp_path, + "case_api", + "int Foo(int value);\nint foo(int value);\n", + "int Foo(int v) { return v + 1; }\nint foo(int v) { return v + 2; }\n", + ["Foo", "foo"], + ) + + assert set(_stated_exports(contract)) == {"Foo", "foo"} + # Each Python name reaches the C function that spells itself that way. + assert module.Foo(np.int32(10)) == np.int32(11) + assert module.foo(np.int32(10)) == np.int32(12) + + +def test_a_mixed_case_c_name_keeps_its_spelling(tmp_path: Path): + """Folding case would rename a declaration C never asked to rename.""" + module, contract = _build( + tmp_path, + "mixed_case_api", + "int BarBaz(int value);\n", + "int BarBaz(int v) { return v + 1; }\n", + ["BarBaz"], + ) + + assert _stated_exports(contract) == ["BarBaz"] + assert module.BarBaz(np.int32(2)) == np.int32(3) + assert "barbaz" not in contract.read_text(encoding="utf-8") 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 1e7f56dbc..aeea6c5bc 100644 --- a/tests/fortran/generic_interfaces/pipeline/test_generated_generic_contracts.py +++ b/tests/fortran/generic_interfaces/pipeline/test_generated_generic_contracts.py @@ -68,7 +68,7 @@ def test_overload_names_its_specific_as_the_contract_declares_it(): code = emit_module( fortran_module_to_semantic_module(parse_fortran_source(source)), - normalize_fortran_public_names=True, + normalize_public_names=True, ) assert "def qradd_rdiag(" in code diff --git a/tests/fortran/infrastructure/codegen/test_planner.py b/tests/fortran/infrastructure/codegen/test_planner.py index f923c976f..6f9f33ecd 100644 --- a/tests/fortran/infrastructure/codegen/test_planner.py +++ b/tests/fortran/infrastructure/codegen/test_planner.py @@ -72,6 +72,27 @@ def right_value(x: Int32) -> Int32: ... assert plan.namespaces[2].functions[0].symbol_name == "right_shared_value" +def test_two_python_names_one_folded_stem_get_separate_generated_symbols(): + """A generated symbol is shared with Fortran, which folds the two together.""" + module = parse_pyi_text( + """ +def left_value(x: Int32) -> Int32: ... +def right_value(x: Int32) -> Int32: ... +""", + module_name="folded", + ) + module.functions[0].metadata[PYTHON_EXPORTS_METADATA] = [{"namespace": (), "name": "Foo"}] + module.functions[1].metadata[PYTHON_EXPORTS_METADATA] = [{"namespace": (), "name": "foo"}] + complete_semantic_policies(module) + + plan = WrapperPlanner().build(module) + + functions = plan.namespaces[0].functions + assert [function.binding.python_name for function in functions] == ["Foo", "foo"] + stems = [function.symbol_name for function in functions] + assert len({stem.casefold() for stem in stems}) == len(stems) + + def test_binding_registers_child_namespaces_as_importable_submodules(): module = parse_pyi_text( """ diff --git a/tests/fortran/infrastructure/naming/test_policy.py b/tests/fortran/infrastructure/naming/test_policy.py index 1a4f1f976..cdc8c0e27 100644 --- a/tests/fortran/infrastructure/naming/test_policy.py +++ b/tests/fortran/infrastructure/naming/test_policy.py @@ -3,7 +3,37 @@ import pytest from prik.naming import NamingPolicy -from prik.naming import normalize_public_name +from prik.naming import normalize_public_name, preserves_source_case + + +def test_only_a_case_insensitive_language_gives_up_its_own_spelling(): + """Case is a name's identity everywhere a source distinguishes two spellings.""" + assert preserves_source_case("c") is True + assert preserves_source_case("pyi") is True + assert preserves_source_case(None) is True + assert preserves_source_case("fortran") is False + assert preserves_source_case("FORTRAN") is False + + +def test_a_folded_name_loses_a_spelling_a_preserved_one_keeps(): + """Folding is right only where the source never meant the two to differ.""" + assert normalize_public_name("BarBaz").name == "barbaz" + assert normalize_public_name("BarBaz", preserve_case=True).name == "BarBaz" + # Python still cannot bind a keyword, whichever rule names the declaration. + assert normalize_public_name("lambda", preserve_case=True).name == "lambda_" + # Casing alone is not a rename, so strict naming has nothing to reject. + assert normalize_public_name("BarBaz", preserve_case=True).needs_fix is False + + +def test_two_spellings_collide_only_where_the_source_folds_them(): + """A case-sensitive source names two declarations; folding invents a collision.""" + folding = NamingPolicy() + assert folding.reserve_public_name((), "Foo", category="function") == "foo" + assert folding.reserve_public_name((), "foo", category="function") == "foo_2" + + preserving = NamingPolicy(preserve_case=True) + assert preserving.reserve_public_name((), "Foo", category="function") == "Foo" + assert preserving.reserve_public_name((), "foo", category="function") == "foo" def test_public_python_names_escape_keywords_and_collisions(): 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 efb0db28a..a95a95d4e 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 @@ -73,7 +73,7 @@ def test_fortran_generated_contracts_reserve_colliding_public_names_by_namespace origin=origin, ) - code = emit_module(module, normalize_fortran_public_names=True) + code = emit_module(module, normalize_public_names=True) assert 'lambda_: Annotated[Int32, SourceName("lambda")]' in code assert 'lambda__2: Annotated[Int32, SourceName("lambda_")]' in code @@ -83,7 +83,7 @@ def test_fortran_generated_contracts_reserve_colliding_public_names_by_namespace def test_pyi_emission_context_isolates_modules_and_shares_nested_imports(): - printer = PyiPrinter(normalize_fortran_public_names=True) + printer = PyiPrinter(normalize_public_names=True) first = printer._emission_context(SemanticModule(name="first")) second = printer._emission_context(SemanticModule(name="second")) nested = first.inside_class("record_t") @@ -540,7 +540,7 @@ def test_generated_contract_imports_a_name_under_the_spelling_its_definition_use stubs = emit_module_stubs( [fortran_module_to_semantic_module(consts), fortran_module_to_semantic_module(infos)], - normalize_fortran_public_names=True, + normalize_public_names=True, ) assert "ik: Final[Int32]" in stubs["consts_mod"] @@ -565,7 +565,7 @@ def test_generated_contract_renames_an_imported_name_under_both_spellings(): stubs = emit_module_stubs( [fortran_module_to_semantic_module(consts), fortran_module_to_semantic_module(renaming)], - normalize_fortran_public_names=True, + normalize_public_names=True, ) assert "from .consts_mod import ik as my_ik" in stubs["renaming_mod"] @@ -605,7 +605,7 @@ def test_generated_contract_imports_a_prototype_under_its_declared_spelling(): stubs = emit_module_stubs( [fortran_module_to_semantic_module(declares), fortran_module_to_semantic_module(solver)], - normalize_fortran_public_names=True, + normalize_public_names=True, ) assert "def OBJ(" in stubs["pintrf_mod"] @@ -633,7 +633,7 @@ def test_fortran_contract_records_no_source_name_for_a_case_only_python_name(): code = emit_module( fortran_module_to_semantic_module(parse_fortran_source(source)), - normalize_fortran_public_names=True, + normalize_public_names=True, ) assert "ik: Final[Int32]" in code @@ -658,7 +658,7 @@ def test_fortran_contract_records_a_source_name_python_cannot_spell(): code = emit_module( fortran_module_to_semantic_module(parse_fortran_source(source)), - normalize_fortran_public_names=True, + normalize_public_names=True, ) assert 'lambda_: Annotated[Int32, SourceName("lambda")]' in code @@ -682,7 +682,7 @@ def test_non_fortran_declaration_compares_its_native_spelling_exactly(): origin=origin, ) - code = emit_module(module, normalize_fortran_public_names=True) + code = emit_module(module, normalize_public_names=True) assert '@bind("ScaleValue")' in code @@ -703,7 +703,7 @@ def test_generated_contract_binds_a_class_whose_python_name_renames_its_type(): origin=origin, ) - code = emit_module(module, normalize_fortran_public_names=True) + code = emit_module(module, normalize_public_names=True) assert '@bind("POINT_T")\nclass PointType:' in code @@ -724,7 +724,7 @@ def test_generated_contract_omits_a_class_bind_for_a_case_only_python_name(): origin=origin, ) - code = emit_module(module, normalize_fortran_public_names=True) + code = emit_module(module, normalize_public_names=True) assert "class point_t:" in code assert "@bind(" not in code @@ -765,7 +765,7 @@ def test_prototype_spelling_is_kept_only_for_the_module_that_declares_one(): stubs = emit_module_stubs( [fortran_module_to_semantic_module(item) for item in (callbacks, values, consumer)], - normalize_fortran_public_names=True, + normalize_public_names=True, ) assert "def OBJ(" in stubs["callback_mod"] @@ -807,7 +807,7 @@ def test_prototype_import_uses_the_declared_spelling_whatever_case_names_it(): stubs = emit_module_stubs( [fortran_module_to_semantic_module(item) for item in (callbacks, user)], - normalize_fortran_public_names=True, + normalize_public_names=True, ) assert "def OBJ(" in stubs["callback_mod"] @@ -840,7 +840,7 @@ def test_import_binds_the_name_a_collision_made_the_declaring_contract_use(): stubs = emit_module_stubs( [fortran_module_to_semantic_module(item) for item in (home, user)], - normalize_fortran_public_names=True, + normalize_public_names=True, ) assert "def lambda__2(" in stubs["collide_home"] @@ -889,7 +889,7 @@ def test_generated_contract_states_the_names_its_source_publishes(): stubs = emit_module_stubs( [fortran_module_to_semantic_module(item) for item in (home, facade, consumer)], - normalize_fortran_public_names=True, + normalize_public_names=True, ) # The publishing module names the import; the consuming one does not. @@ -918,7 +918,7 @@ def test_a_published_intrinsic_name_states_no_contract_import(): """ module = fortran_module_to_semantic_module(parse_fortran_source(source)) - code = emit_module(module, normalize_fortran_public_names=True) + code = emit_module(module, normalize_public_names=True) assert [reexport.origin_module for reexport in module.reexports] == ["iso_fortran_env", "iso_fortran_env"] assert "iso_fortran_env" not in code @@ -953,7 +953,7 @@ def test_generated_contract_omits_a_republication_no_build_can_expose(): modules = fortran_file_to_semantic_modules(parsed) facade = next(module for module in modules if module.name == "state_facade") - stubs = emit_module_stubs(modules, normalize_fortran_public_names=True) + stubs = emit_module_stubs(modules, normalize_public_names=True) assert sorted((item.local_name, item.entity_kind) for item in facade.reexports) == [ ("bump", "procedure"), 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 76c8a0fb9..d76ee32d9 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 @@ -75,7 +75,7 @@ def test_fortran_generated_contracts_emit_python_name_without_binding_the_same_n origin=SemanticOrigin(source_language="fortran", source_kind="module"), ) - code = emit_module(module, normalize_fortran_public_names=True) + code = emit_module(module, normalize_public_names=True) assert "def square_r4(" in code assert "@bind(" not in code From c8771d582b64dc6d64e14610fffb08e155d0d85f Mon Sep 17 00:00:00 2001 From: said Date: Wed, 16 Sep 2026 09:22:41 +0100 Subject: [PATCH 32/96] Name the defect where one decision gets derived twice The forward-only rule forbids a later stage from reinterpreting or overriding an upstream decision. It said nothing about deriving the same answer again, which is what the .pyi printer did with public names: its own NamingPolicy, its own collision counter, and a Fortran gate the owner did not have. That reads as reuse at the call site, so it survived review and tests on both sides while the two answers disagreed. Record the decisive question and the two acceptable resolutions, and require a test comparing the artifacts wherever one decision reaches users through two of them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- AGENTS.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 2744026da..3d2341100 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,33 @@ the selected plan requires a genuinely new emitted-code mechanism; those generators should otherwise keep reusing and dispatching existing planned paths. +A decision is read, not recomputed. Completed policy moving forward also means a +later stage must not derive the same answer a second time, which is harder to +notice than an override because the second site often calls the same helper and +so reads as reuse rather than as a second authority. When two places need one +answer, ask: **if these two call sites disagreed, which one would be wrong?** If +that has no answer, the decision has two authorities and no owner; if it has +one, the other site must read the answer rather than compute it. This applies to +derivation carrying state or a condition — a collision counter, a reservation +ledger, a language gate, a default — because that is what drifts; calling a +pure, total helper from several stages is fine. Prefer reading the owner's +recorded output: the completed policy, the shared plan, or the metadata the +owner wrote. Where that output genuinely is unavailable at that point — contract +extraction runs without policy completion, because completion rejects C the +direct route cannot build — call the owner's same entrypoint with the same +inputs, never a local variant and never an extra condition the owner does not +have. + +Where one decision reaches users through two artifacts, a test must compare +those artifacts rather than only check each one. A built extension and the +`.pyi` contract describing it are one such pair: each had passing tests while +the names they published disagreed, because nothing asserted that they agreed. +Treat the same comparison as a recommendation, not a requirement, for internal +pairs such as a wrapper plan and the sources generated from it. Watch for a +second policy or allocator instance, for a language, route, or flag gate at the +consumer that the owner lacks, and for a `prik/printers/` helper that returns a +name, kind, or decision rather than text. + To answer an ABI question, or to decide whether something belongs in the binding or in the Fortran bridge, first ask: **how would this work for a `bind(C)` procedure, where there is no bridge at all?** A direct entrypoint has From 664c48e7519cf52f282ccce10ae0c00f2cd6dbfe Mon Sep 17 00:00:00 2001 From: said Date: Wed, 16 Sep 2026 15:12:22 +0100 Subject: [PATCH 33/96] Decide a public name once, and let every stage write that one Naming lived in two places. Post-IR export policy decided it for the build, and the .pyi printer decided it again for the contract -- its own allocator, its own collision counter, and a Fortran-only gate. The two walked declarations in different orders, policy taking classes, functions, overloads then variables while the printer took variables before functions, so a module variable and a procedure whose names both normalize to lambda_ were settled one way by the build and the other way by the contract. The contract then held the right set of names attached to the wrong declarations, and a C function named for a Python keyword produced "def lambda(", which does not parse at all. Policy now owns every public name and every other stage reads it: the printer through the completed export metadata, the planner through the name recorded on the re-export. Re-exports join the same namespace ledger, so an imported alias can no longer take a name one of the module's own declarations holds. The contract a source build leaves beside its artifacts states published names too, where it used to state raw source spellings. Making the rule shared exposed that it was Fortran's rule. Folding case is right there, because Fortran writes one declaration under many spellings and none of them is its own; C names each declaration exactly, so folding lost the name and invented collisions the source does not have. And a wrapped type is what Python calls a class, so it is spelled like one: each underscore-separated word capitalized, point_t reaching Python as Point_T. A contract that states a different name still wins, which is what makes this a default rather than a constraint. Two more sites had split the same way and only agreed while every name was lower case. The helper attaching native storage was defined from the published name but looked up by the native one, and a callback's expected result type was fetched by the native name from a namespace publishing the Python one; the second aborted the interpreter rather than raising. Docstrings rendered the native name for a wrapped type as well. A module variable and a generic are checked against the namespace declaring them rather than counted, because withholding one at home and listing it on a facade publishes it exactly once and still not where it lives. This changes the published class names of existing Fortran wrappers and the published names of existing C wrappers. --- AGENTS.md | 17 +- CHANGELOG.md | 37 +++- docs/developer/packages/codegen.md | 2 +- docs/index.md | 12 +- docs/user/guide/wrapping-derived-types.md | 54 ++--- docs/user/reference/pyi-format.md | 41 ++-- prik/cli.py | 11 +- prik/codegen/c/binding.py | 41 +++- prik/codegen/c/naming.py | 14 +- prik/codegen/docstrings.py | 16 +- prik/naming/policy.py | 32 ++- prik/pipeline/build.py | 30 ++- prik/pipeline/pyi.py | 22 +- prik/planning/planner.py | 8 +- prik/policy/exports.py | 75 +++++-- prik/printers/pyi.py | 168 +++++++++++++-- prik/semantics/models.py | 18 ++ tests/fortran/_support/wrapper_build.py | 10 +- .../fallocatable_views_f90.pyi | 4 +- .../end_to_end/test_allocatable_handles.py | 10 +- .../codegen/test_callback_planning.py | 2 +- .../fcallback_all_f90/fcallback_all_f90.pyi | 12 +- .../test_multi_file_contract_generation.py | 2 +- .../test_supported_callback_shapes.py | 6 +- .../codegen/test_class_surfaces.py | 12 +- .../fbind_c_derived_layout_f90.pyi | 12 +- .../fborrowed_finalizer_f90.pyi | 8 +- .../contracts/fclasses_f90/fclasses_f90.pyi | 26 +-- .../fconstructors_f90/fconstructors_f90.pyi | 4 +- .../fderived_boundary_f90.pyi | 24 +-- .../finheritance_f90/finheritance_f90.pyi | 18 +- .../fmodule_derived_alias_f90.pyi | 6 +- .../derived_types_direct_bind_c_f90.pyi | 8 +- .../derived_types_mixed_bind_c_f90.pyi | 8 +- .../end_to_end/test_abstract_hierarchy.py | 28 +-- .../end_to_end/test_borrowed_components.py | 2 +- ...est_default_constructors_and_finalizers.py | 10 +- .../end_to_end/test_derived_boundaries.py | 10 +- .../test_derived_direct_entrypoint_routing.py | 6 +- .../test_derived_runtime_mechanisms.py | 2 +- .../end_to_end/test_generic_constructor.py | 14 +- .../test_inheritance_and_polymorphism.py | 16 +- .../end_to_end/test_module_derived_aliases.py | 4 +- .../end_to_end/test_opaque_layout.py | 2 +- .../end_to_end/test_type_accessibility.py | 6 +- .../end_to_end/test_type_bound_methods.py | 6 +- .../contracts/fenums_f90/fenums_f90.pyi | 4 +- .../end_to_end/test_enum_runtime.py | 2 +- .../codegen/test_overload_dispatch_plan.py | 2 +- .../foperators_f90/foperators_f90.pyi | 194 +++++++++--------- .../foverloads_f90/foverloads_f90.pyi | 18 +- .../end_to_end/test_defined_operators.py | 12 +- .../end_to_end/test_generic_interfaces.py | 4 +- .../combined_modules/box_ops.pyi | 2 +- .../combined_modules/shared_types.pyi | 6 +- .../end_to_end/test_multi_source_builds.py | 10 +- .../end_to_end/test_source_build_modes.py | 2 +- .../infrastructure/naming/test_policy.py | 24 +++ .../contracts/fnaming_f90/fnaming_f90.pyi | 6 +- .../test_contract_names_match_build.py | 137 +++++++++++++ .../end_to_end/test_visibility_naming.py | 4 +- .../test_declaring_namespace_publication.py | 165 +++++++++++++++ .../test_pyi_printer_imports_and_packages.py | 33 ++- .../fmodule_vars_f90/fmodule_vars_f90.pyi | 6 +- .../test_module_variables_and_state.py | 2 +- .../contracts/foptional_f90/foptional_f90.pyi | 6 +- .../end_to_end/test_optional_runtime.py | 2 +- .../end_to_end/test_pointer_handles.py | 2 +- .../end_to_end/test_assumed_scalar_intent.py | 2 +- .../test_documented_subroutine_journey.py | 2 +- 70 files changed, 1110 insertions(+), 413 deletions(-) create mode 100644 tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/test_contract_names_match_build.py create mode 100644 tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/pipeline/test_declaring_namespace_publication.py diff --git a/AGENTS.md b/AGENTS.md index 3d2341100..c2d1cac26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,13 +107,16 @@ that has no answer, the decision has two authorities and no owner; if it has one, the other site must read the answer rather than compute it. This applies to derivation carrying state or a condition — a collision counter, a reservation ledger, a language gate, a default — because that is what drifts; calling a -pure, total helper from several stages is fine. Prefer reading the owner's -recorded output: the completed policy, the shared plan, or the metadata the -owner wrote. Where that output genuinely is unavailable at that point — contract -extraction runs without policy completion, because completion rejects C the -direct route cannot build — call the owner's same entrypoint with the same -inputs, never a local variant and never an extra condition the owner does not -have. +pure, total helper from several stages is fine. Read the owner's recorded +output: the completed policy, the shared plan, or the metadata the owner wrote. +Where a stage cannot run the owner's full completion, run the narrower +completion step for that one decision rather than deriving it again — contract +extraction must describe C that the direct-only wrapper would reject, so +`emit_module_stubs` completes public-name policy for every module and the rest +only where a build request allows it. Sharing the owner's helper is not enough +when the derivation keeps a ledger: two allocators fed the same declarations in +a different order produce the same set of names attached to different +declarations, which every per-stage test still passes. Where one decision reaches users through two artifacts, a test must compare those artifacts rather than only check each one. A built extension and the diff --git a/CHANGELOG.md b/CHANGELOG.md index 85a5d8cd1..a7b309440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,36 @@ release tags add a leading `v` to the package version. ## Unreleased -- A wrapper's Python names are decided once, by `prik.naming`, and every stage - that writes a name implements that decision. The `.pyi` printer re-derived - them instead, and only for Fortran, so a build and the contract describing it - could disagree: a C function named for a Python keyword built as `lambda_` - while its contract said `def lambda(`, which is not Python at all. A contract - now names exactly what the build beside it publishes, and records the source - spelling with `@bind` wherever the two differ. +- A module variable and a generic are published only by the namespace declaring + them, which is now checked against that namespace rather than by counting the + namespaces publishing them. Counting caught a contract listing one beside its + declaring contract but not one moving it to a facade, because withholding the + name at home left exactly one publisher -- a relocation the source route has + no way to produce. A procedure and a derived type each reach Python as one + object and are re-exported through aliases as before. + +- A wrapper's Python names are decided once, by post-IR export policy, and + every stage that writes a name reads that decision. The `.pyi` printer + allocated its own instead, so a build and the contract describing it could + disagree: a C function named for a Python keyword built as `lambda_` while + its contract said `def lambda(`, which is not Python at all. Worse, the two + allocators walked declarations in different orders -- policy takes classes, + functions, overloads, then variables, the printer took variables before + functions -- so a module variable and a procedure whose names both normalize + to `lambda_` were settled one way by the build and the other way by the + contract. The contract then held the right set of names attached to the wrong + declarations. A contract now names exactly what the build beside it + publishes, derived types included, and records the source spelling with + `@bind` wherever the two differ. + +- A wrapped type is spelled like the Python class it becomes. Fortran writes + one declaration under many spellings, so PRIK picks one, and picking + `point_t` for something used as `Point_T(...)` read as a function. A derived + type now publishes with each underscore-separated word capitalized -- + `type :: point_t` reaches Python as `Point_T` -- while every other Fortran + declaration stays lowercased. Renaming one in the contract still works, so + this is a default rather than a constraint. This changes the published class + names of existing Fortran wrappers. - A C declaration keeps the case it is written in. Folding it is a Fortran rule, correct there because Fortran writes one declaration many ways and none of diff --git a/docs/developer/packages/codegen.md b/docs/developer/packages/codegen.md index 5d9885219..716500fd0 100644 --- a/docs/developer/packages/codegen.md +++ b/docs/developer/packages/codegen.md @@ -219,7 +219,7 @@ class State: def __new__(cls, *args, **kwargs): 'Construction is disabled.' raise TypeError('State objects come from native code.') -def _prik_wrap_State(capsule, owner=None, ops=None, origin='direct'): +def _prik_wrap_state_t(capsule, owner=None, ops=None, origin='direct'): ... ``` diff --git a/docs/index.md b/docs/index.md index d98b62e38..48f893c22 100644 --- a/docs/index.md +++ b/docs/index.md @@ -173,7 +173,7 @@ python3 -m prik points.f90 --out geometry import numpy as np import geometry.points as points -item = points.point(x=np.float64(3.0), y=np.float64(4.0)) +item = points.Point(x=np.float64(3.0), y=np.float64(4.0)) points.move(item, np.float64(1.0), np.float64(-2.0)) print(item.x, item.y) # 4.0 2.0 @@ -191,16 +191,16 @@ The generated `points.pyi` is: ```python from prik.contracts import Addr, Arg, Float64, native_call -class point: +class Point: x: Float64 = 0.0 y: Float64 = 0.0 def __init__(self, *, x: Float64 = 0.0, y: Float64 = 0.0) -> None: ... @native_call([Arg(0), Addr(Arg(1)), Addr(Arg(2))]) -def move(item: point, dx: Float64, dy: Float64) -> None: ... +def move(item: Point, dx: Float64, dy: Float64) -> None: ... -def norm_squared(item: point) -> Float64: ... +def norm_squared(item: Point) -> Float64: ... ``` Generate it: @@ -220,7 +220,7 @@ The edited `points.pyi` is: ```python from prik.contracts import Addr, Arg, Float64, Pass, bind, native_call -class point: +class Point: x: Float64 = 0.0 y: Float64 = 0.0 @@ -260,7 +260,7 @@ The native Fortran is unchanged, but the Python surface is now: import numpy as np import geometry.points as points -item = points.point(x=np.float64(3.0), y=np.float64(4.0)) +item = points.Point(x=np.float64(3.0), y=np.float64(4.0)) item.translate(np.float64(1.0), np.float64(-2.0)) print(item.x, item.y) # 4.0 2.0 diff --git a/docs/user/guide/wrapping-derived-types.md b/docs/user/guide/wrapping-derived-types.md index 16c1ffc87..e410e8e8e 100644 --- a/docs/user/guide/wrapping-derived-types.md +++ b/docs/user/guide/wrapping-derived-types.md @@ -93,7 +93,7 @@ The generated `points.pyi` is: ```python from prik.contracts import Addr, Arg, Float64, native_call -class point: +class Point: def __init__( self, *, @@ -104,14 +104,14 @@ class point: x: Float64 = 0.0 y: Float64 = 0.0 -class holder: +class Holder: def __init__(self) -> None: ... - origin: point + origin: Point @native_call([Arg(0), Addr(Arg(1)), Addr(Arg(2))]) def move( - item: point, + item: Point, dx: Float64, dy: Float64 ) -> None: ... @@ -120,11 +120,11 @@ def move( def make_point( x: Float64, y: Float64 -) -> point: ... +) -> Point: ... def set_origin( - container: holder, - item: point + container: Holder, + item: Point ) -> None: ... ``` @@ -149,7 +149,7 @@ sys.path.insert(0, "build/geometry") import geometry.points as points # Create new object -item = points.point(x=np.float64(1.0), y=np.float64(2.0)) +item = points.Point(x=np.float64(1.0), y=np.float64(2.0)) # Call method (inout mutation) points.move(item, np.float64(3.0), np.float64(4.0)) @@ -180,17 +180,17 @@ Result: The class docstring gives a short index: ```python -print(points.point.__doc__) +print(points.Point.__doc__) ``` ```text -point +Point Opaque wrapper for native type point. Constructor ----------- -point(*, x=0.0, y=0.0) -> point +Point(*, x=0, y=0) -> Point Fields ------ @@ -201,7 +201,7 @@ y : float64 The constructor has its own detailed docstring: ```python -print(points.point.__init__.__doc__) +print(points.Point.__init__.__doc__) ``` --- @@ -281,12 +281,12 @@ as the constructor. In this mapping, `@bind` selects the native initializer, `@native_call(...)` gives its argument order, `Pass()` inserts the new -`point`, and `Addr(Arg(i))` passes Python argument `i` by address: +`Point`, and `Addr(Arg(i))` passes Python argument `i` by address: ```python from prik.contracts import Addr, Arg, Float64, Pass, bind, native_call -class point: +class Point: x: Float64 y: Float64 @@ -299,8 +299,8 @@ Replace the generated field-keyword `__init__` declaration with this one. The edit changes construction only; it does not create `initialize_point` in the native module. -After rebuilding, `points.point.__init__.__doc__` starts with -`point(x, y) -> point` and lists both parameters. +After rebuilding, `points.Point.__init__.__doc__` starts with +`Point(x, y) -> Point` and lists both parameters. For the complete replacement rules, see [Replace the Constructor](../reference/pyi-contracts/functions-and-classes.md#replace-the-constructor). @@ -349,12 +349,12 @@ the module declaration: ```python from prik.contracts import Addr, Arg, Float64, Pass, native_call -class point: +class Point: @native_call([Pass(), Addr(Arg(0)), Addr(Arg(1))]) def move(self, dx: Float64, dy: Float64) -> None: ... @native_call([Arg(0), Addr(Arg(1)), Addr(Arg(2))]) -def move(item: point, dx: Float64, dy: Float64) -> None: ... +def move(item: Point, dx: Float64, dy: Float64) -> None: ... ``` Both declarations call the existing native `move` procedure: @@ -402,7 +402,7 @@ Fortran one: | `procedure, public ::` on a binding | Published regardless of the type default | The class docstring now lists `move(dx, dy) -> None` under `Methods`. -`points.point.move.__doc__` contains its complete parameter and return details. +`points.Point.move.__doc__` contains its complete parameter and return details. For the complete mapping rules, see [Expose a Module Procedure as a Method](../reference/pyi-contracts/functions-and-classes.md#expose-a-module-procedure-as-a-method). @@ -556,7 +556,7 @@ generic: ```python from prik.contracts import Float64, Int32, bind, overload, private -class counter: +class Counter: @private def add_integer(self, amount: Int32) -> Int32: ... @@ -610,19 +610,19 @@ The generated contract exposes `operator(+)` as `__add__`: ```python from prik.contracts import overload, private -class point: +class Point: @overload("add_points") - def __add__(self, right: point) -> point: ... + def __add__(self, right: Point) -> Point: ... @private -def add_points(left: point, right: point) -> point: ... +def add_points(left: Point, right: Point) -> Point: ... ``` Python uses the normal operator: ```python -left = points.point(x=np.float64(1.0), y=np.float64(2.0)) -right = points.point(x=np.float64(3.0), y=np.float64(4.0)) +left = points.Point(x=np.float64(1.0), y=np.float64(2.0)) +right = points.Point(x=np.float64(3.0), y=np.float64(4.0)) total = left + right print(total.x, total.y) # 4.0 6.0 ``` @@ -630,7 +630,7 @@ print(total.x, total.y) # 4.0 6.0 The magic method docstring shows the accepted operator signatures: ```python -print(points.point.__add__.__doc__) +print(points.Point.__add__.__doc__) ``` The relevant part is: @@ -640,7 +640,7 @@ __add__(*args, **kwargs) Supported Signatures -------------------- -__add__(right: point) -> point +__add__(right: Point) -> Point ``` | Fortran generic | Python method | Python syntax | diff --git a/docs/user/reference/pyi-format.md b/docs/user/reference/pyi-format.md index a65ce054c..2a99a2246 100644 --- a/docs/user/reference/pyi-format.md +++ b/docs/user/reference/pyi-format.md @@ -209,11 +209,17 @@ name declares decides how publishing it appears: | Derived type | A runtime type. | | Package sub-namespace | A runtime namespace attribute. | | Prototype | A callback signature contracts name, with no runtime object. | -| Module variable | Only in the namespace declaring it; republishing is unsupported. | -| Generic interface | Only in the namespace declaring it; republishing is unsupported. | - -A name whose kind cannot reach a second namespace is refused rather than -published differently from a build of the same Fortran source. +| Module variable | Live state, publishable only by the namespace declaring it. | +| Generic interface | A dispatch surface, publishable only by the namespace declaring it. | + +A procedure and a derived type each reach Python as one object, so another +namespace can bind that object and PRIK re-exports it under whatever name the +importing contract states. A module variable and a generic reach Python as +neither, so no other namespace can publish one. Every namespace naming one of +those two kinds in its `__all__` is checked against the namespace declaring it: +listing it beside the declaring contract is refused, and so is moving it to a +facade by withholding it at home, which publishes it in exactly one namespace +and still not the one it lives in. PRIK writes the list into every generated contract, holding what the Fortran source publishes: the module's own public declarations, and any imported name it @@ -1011,14 +1017,25 @@ These are separate operations. When PRIK generates a contract it adds a trailing underscore to Python keywords, normalizes other invalid Python identifiers, and gives remaining -collisions deterministic numeric suffixes. A Fortran identifier is lowercased -as well, because Fortran writes one declaration under many spellings and none -of them is the declaration's own. A C identifier keeps its case: C names each -declaration exactly, so `BarBaz` stays `BarBaz`, and `Foo` and `foo` stay two -functions. The same policy covers module members, classes, methods, fields, and -argument names, and it decides the names a build publishes and the names the +collisions deterministic numeric suffixes. + +PRIK chooses a spelling only where the source has none. Fortran writes one +declaration under many spellings, so PRIK picks: a wrapped type becomes a +Python class and is spelled like one, capitalizing each underscore-separated +word, and every other declaration is lowercased. `type :: point_t` publishes as +`Point_T`, and `subroutine SCALE_VALUE` as `scale_value`. C names each +declaration exactly, so its spelling is kept as written: `BarBaz` stays +`BarBaz`, `struct point` stays `point`, and `Foo` and `foo` remain two +functions. + +These are defaults, not constraints. Rename a declaration in the contract and +the build follows it, because a Fortran name resolves without regard to case +and `@bind(...)` states a native name that differs from the Python one. The +same policy covers module members, classes, methods, fields, and argument +names, and it decides both the names a build publishes and the names the contract describing that build states. `--strict-wrapper-names` rejects a -generated name that would need any of these fixes. +generated name Python could not otherwise spell; it does not object to the +chosen casing. Fortran `bind(C, name=...)` changes the native symbol, not the Python name. In an edited contract, `@bind("native_name")` records that native-name distinction; diff --git a/prik/cli.py b/prik/cli.py index edafceb7d..90067f463 100644 --- a/prik/cli.py +++ b/prik/cli.py @@ -658,7 +658,6 @@ def _convert_fortran_semantic_sources( def _semantic_payload_for_converted_files(converted_files) -> dict[str, dict]: from prik.pipeline.pyi import emit_module_stubs - from prik.printers import emit_module out: dict[str, dict] = {} available_modules = [module for _p, modules in converted_files for module in modules] @@ -669,10 +668,12 @@ def _semantic_payload_for_converted_files(converted_files) -> dict[str, dict]: continue if _is_c_semantic_file(modules): # A generated C starter contract preserves raw source facts, even - # for a form that the direct-only wrapper policy will later block. - # ``--pyi`` is contract extraction, not wrapper planning. The names - # are still C's, so they are written the way a build publishes them. - module_stubs = {module.name: emit_module(module, normalize_public_names=True).strip() for module in modules} + # for a form that the direct-only wrapper policy will later block: + # ``--pyi`` is contract extraction, not wrapper planning. Emission + # still goes through the shared stub pipeline, which completes the + # public names policy owns without completing wrapper policy. + stubs = emit_module_stubs(modules, normalize_public_names=True) + module_stubs = {module.name: stubs[module.name] for module in modules} out[str(p)] = { "semantic_modules": [asdict(module) for module in modules], "pyi": "\n\n".join(module_stubs.values()).strip(), diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 875200e66..645423a1d 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -336,6 +336,12 @@ def binding_module(self, plan: ModulePlan) -> CModule: for surface in namespace.classes if surface.python_names } + # Generated code fetching a wrapped type out of its namespace needs the + # name that namespace published it under. That is planned once, here, + # so no emission site re-derives it from the native type name. + self._class_python_names_by_type = { + identity[1].casefold(): name for identity, name in class_python_names.items() + } # Stage 3: select support and assemble generated functions in dependency order. functions = tuple(function for namespace in plan.namespaces for function in self.visit(namespace)) needs_native_support = self.requires_native_support(plan) @@ -961,6 +967,22 @@ def _callback_trampoline_function(self, callback: CallbackHandoffPlan) -> CFunct body=tuple(nodes), ) + @staticmethod + def _wrap_helper_attribute(semantic_type_name: object) -> str: + """Return the internal helper attaching native storage for one type. + + The helper is keyed on the native type's own name, the way + ``CBindingNames.class_wrap_helper`` defines it, so the attribute does + not move when naming policy publishes the type under a different name + and a contract naming its classes in Python still resolves it. + """ + return f"_prik_wrap_{str(semantic_type_name).casefold()}" + + def _published_class_name(self, semantic_type_name: str) -> str: + """Return the name the namespace published one wrapped type under.""" + index = getattr(self, "_class_python_names_by_type", {}) + return index.get(str(semantic_type_name).casefold(), str(semantic_type_name)) + @staticmethod def _callback_abort_if_null( callback: CallbackHandoffPlan, @@ -1144,7 +1166,7 @@ def _callback_derived_nodes( helper, "PyObject *", CodeExpression( - f'PyObject_GetAttrString(callback_context->module, "_prik_wrap_{transfer.semantic_type_name}")' + f'PyObject_GetAttrString(callback_context->module, "{self._wrap_helper_attribute(transfer.semantic_type_name)}")' ), ), CDeclaration( @@ -1301,7 +1323,10 @@ def _callback_derived_result_nodes( CDeclaration( "callback_expected_type", "PyObject *", - CodeExpression(f'PyObject_GetAttrString({context}->module, "{transfer.semantic_type_name}")'), + CodeExpression( + f"PyObject_GetAttrString({context}->module, " + f'"{self._published_class_name(transfer.semantic_type_name)}")' + ), ), self._callback_abort_if_null( callback, @@ -3576,7 +3601,7 @@ def _borrowed_derived_wrapper_nodes( CDeclaration( "child_helper", "PyObject *", - CodeExpression(f'PyObject_GetAttrString(self, "_prik_wrap_{type_name}")'), + CodeExpression(f'PyObject_GetAttrString(self, "{self._wrap_helper_attribute(type_name)}")'), ), CIf( CodeExpression("child_helper == NULL"), @@ -6491,7 +6516,7 @@ def _lower_module_getter_derived_value_copy(self, plan: ModuleVariablePlan) -> t CDeclaration( helper, "PyObject *", - CodeExpression(f'PyObject_GetAttrString({owner}, "_prik_wrap_{type_name}")'), + CodeExpression(f'PyObject_GetAttrString({owner}, "{self._wrap_helper_attribute(type_name)}")'), ), CIf( CodeExpression(f"{helper} == NULL"), @@ -6526,7 +6551,7 @@ def _module_derived_wrapper_nodes( CDeclaration( "helper", "PyObject *", - CodeExpression(f'PyObject_GetAttrString({owner}, "_prik_wrap_{type_name}")'), + CodeExpression(f'PyObject_GetAttrString({owner}, "{self._wrap_helper_attribute(type_name)}")'), ), CIf( CodeExpression("helper == NULL"), @@ -10073,7 +10098,9 @@ def _lower_result_derived( CDeclaration( helper, "PyObject *", - CodeExpression(f'PyObject_GetAttrString(self, "_prik_wrap_{plan.derived.type_name}")'), + CodeExpression( + f'PyObject_GetAttrString(self, "{self._wrap_helper_attribute(plan.derived.type_name)}")' + ), ), CIf( CodeExpression(f"{helper} == NULL"), @@ -10170,7 +10197,7 @@ def _holder_wrapper_nodes( CDeclaration( helper, "PyObject *", - CodeExpression(f'PyObject_GetAttrString(self, "_prik_wrap_{type_name}")'), + CodeExpression(f'PyObject_GetAttrString(self, "{self._wrap_helper_attribute(type_name)}")'), ), CIf( CodeExpression(f"{helper} == NULL"), diff --git a/prik/codegen/c/naming.py b/prik/codegen/c/naming.py index da249ae76..611de6c00 100644 --- a/prik/codegen/c/naming.py +++ b/prik/codegen/c/naming.py @@ -113,10 +113,18 @@ def class_wrap_helper( *, fallback: str | None = None, ) -> str: - """Return the Python helper attaching existing native storage.""" - name = surface.python_names[0] if surface is not None else fallback + """Return the Python helper attaching existing native storage. + + The helper is internal, and the generated code reaching for it knows + the native type it is wrapping rather than the name Python publishes + that type under, so it is keyed on the type's own identity the way + ``class_create_method`` is. Keying it on the published name instead + would move it whenever naming policy spells the class differently and + leave every such lookup resolving to nothing. + """ + name = surface.type_identity[1].casefold() if surface is not None else fallback if name is None: - raise ValueError("Class wrapper helper requires a Python type name") + raise ValueError("Class wrapper helper requires a native type name") return f"_prik_wrap_{name}" @staticmethod diff --git a/prik/codegen/docstrings.py b/prik/codegen/docstrings.py index 508d41de8..9f59f7838 100644 --- a/prik/codegen/docstrings.py +++ b/prik/codegen/docstrings.py @@ -89,6 +89,15 @@ def render(self, plan: ModulePlan) -> ModulePlan: are explicit plan overrides and remain unchanged. The same plan is returned for generation-stage chaining. """ + # A docstring documents the Python API, so a wrapped type is named the + # way its namespace publishes it. Planning settled that name; indexing + # it here keeps every rendered signature reading the same one. + self._published_class_names = { + surface.type_identity[1].casefold(): surface.python_names[0] + for namespace in plan.namespaces + for surface in namespace.classes + if surface.python_names + } for namespace in plan.namespaces: self._render_namespace(plan.owner_path, namespace) return plan @@ -915,6 +924,11 @@ def _type(self, transfer, *, nullable: bool, signature: bool) -> str: return type_name return f"{type_name} | None" if signature else f"{type_name} or None" + def _published_class_name(self, semantic_type_name: object) -> str: + """Return the name a namespace publishes one wrapped type under.""" + index = getattr(self, "_published_class_names", {}) + return index.get(str(semantic_type_name).casefold(), str(semantic_type_name)) + def _base_type(self, transfer) -> str: """Map one completed transfer family and storage facet to public type text. @@ -925,7 +939,7 @@ def _base_type(self, transfer) -> str: if getattr(transfer, "datatype_family", None) is DatatypeFamily.CALLBACK: return self._callback_type(transfer.callback) if getattr(transfer, "datatype_family", None) is DatatypeFamily.DERIVED: - return transfer.semantic_type_name + return self._published_class_name(transfer.semantic_type_name) scalar = _SCALAR_TYPES.get(transfer.semantic_type_name, transfer.semantic_type_name) array_element = _ARRAY_ELEMENT_TYPES.get(transfer.semantic_type_name, scalar) handle = getattr(transfer, "native_array_handle", None) diff --git a/prik/naming/policy.py b/prik/naming/policy.py index 55611d44b..ec3ec748b 100644 --- a/prik/naming/policy.py +++ b/prik/naming/policy.py @@ -67,12 +67,29 @@ def preserves_source_case(source_language: object) -> bool: return str(source_language or "").casefold() not in _CASE_INSENSITIVE_SOURCE_LANGUAGES -def normalize_public_name(raw_name: object, *, preserve_case: bool = False) -> NormalizedPublicName: +def _capitalized_words(name: str) -> str: + """Return one identifier with each underscore-separated word capitalized.""" + return "_".join(word[:1].upper() + word[1:] for word in name.split("_")) + + +def normalize_public_name( + raw_name: object, + *, + preserve_case: bool = False, + category: str = "function", +) -> NormalizedPublicName: """Convert a source spelling into a valid Python identifier. - The result is lower-cased unless ``preserve_case`` says the source casing - is part of the name; see ``preserves_source_case``. Either way the spelling - is only adjusted where Python cannot accept it. + PRIK chooses a spelling only where the source has none. ``preserve_case`` + says the source casing is part of the name (see ``preserves_source_case``), + and then the spelling is adjusted only where Python cannot accept it. + Otherwise the choice is PRIK's: a wrapped type reaches Python as a class, + so a ``class`` capitalizes each word -- ``point_t`` becomes ``Point_T`` -- + and every other declaration is lower-cased. + + ``needs_fix`` reports only the adjustments Python forced, never the chosen + style, so ``--strict-wrapper-names`` rejects a name Python cannot spell + rather than one PRIK merely cased. """ source = str(raw_name).strip() candidate = source if preserve_case else source.casefold() @@ -81,7 +98,10 @@ def normalize_public_name(raw_name: object, *, preserve_case: bool = False) -> N normalized = f"_{normalized}" if keyword.iskeyword(normalized): normalized = f"{normalized}_" - return NormalizedPublicName(normalized, needs_fix=normalized != candidate) + needs_fix = normalized != candidate + if not preserve_case and category == "class": + normalized = _capitalized_words(normalized) + return NormalizedPublicName(normalized, needs_fix=needs_fix) class NamingPolicy: @@ -101,7 +121,7 @@ def reserve_public_name( owner: object | None = None, ) -> str: """Reserve one public Python name within its namespace.""" - normalized = normalize_public_name(raw_name, preserve_case=self.preserve_case) + normalized = normalize_public_name(raw_name, preserve_case=self.preserve_case, category=category) raw_text = str(raw_name) namespace_key = tuple(str(part) for part in namespace) namespace_text = ".".join(namespace_key) or "" diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index 79a19198a..384c73d79 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -2109,7 +2109,7 @@ def _apply_pyi_python_exports(entry: Path, modules_by_path: dict[Path, SemanticM ) ) exports[:] = [primary] - _reject_unsupported_republication(path, module) + _reject_unsupported_republication(path, module, home) def _pyi_export_tree( @@ -2254,25 +2254,35 @@ def _merge_export_child(tree: _PyiExportNode, name: str, child: _PyiExportNode, ) -def _reject_unsupported_republication(path: Path, module: SemanticModule) -> None: - """Refuse a published name whose kind reaches Python through one namespace. +def _reject_unsupported_republication( + path: Path, + module: SemanticModule, + home: tuple[str, ...] | None, +) -> None: + """Refuse a published name whose kind reaches Python only where it is declared. A module variable holds state that stays live where it is declared, and a - generic is a dispatch surface rather than one object, so neither can be - bound a second time. A source build publishes neither, and a contract that - asks for it says what no build can do rather than quietly differing. + generic is a dispatch surface rather than one object, so neither reaches + Python as an object another namespace can bind. A procedure or a derived + type does, which is why those two are re-exported through aliases instead. + + Every namespace publishing one of these kinds is therefore checked against + the namespace declaring it, not merely counted: moving one to a facade + publishes it in exactly one place and still says what no build can do. """ for declaration, kind in ( *((item, "module variable") for item in module.variables), *((item, "generic") for item in module.overload_sets), ): exports = _declaration_exports(declaration) - if len(exports) < 2: + relocated = [export for export in exports if home is None or tuple(export["namespace"]) != home] + if not relocated: continue - namespaces = ", ".join(".".join(export["namespace"]) or "" for export in exports) + declaring = "" if home is None else (".".join(home) or "") + namespaces = ", ".join(".".join(export["namespace"]) or "" for export in relocated) raise ValueError( - f"{path}: {kind} {declaration.name!r} is published by more than one contract " - f"({namespaces}); republishing this kind is not supported" + f"{path}: {kind} {declaration.name!r} is declared in {declaring} and published in " + f"{namespaces}; this kind is publishable only by the namespace declaring it" ) diff --git a/prik/pipeline/pyi.py b/prik/pipeline/pyi.py index bde2ab70d..08c6f4a7d 100644 --- a/prik/pipeline/pyi.py +++ b/prik/pipeline/pyi.py @@ -16,6 +16,7 @@ from prik.parsers.pyi import parse_pyi_text from prik.policy.completion import complete_semantic_policies +from prik.policy.exports import complete_python_export_policy from prik.printers.pyi import PyiPrinter, emit_module from prik.semantics.models import EXTERNAL_TYPE_REF_METADATA, SemanticClass, SemanticModule, _module_semantic_types from prik.semantics.pyi_metadata import PYI_LOADED_METADATA @@ -116,6 +117,7 @@ def emit_module_stubs( generated contract package by a pipeline stage. """ source_modules = _module_list(modules) + available = _module_list(available_modules) if available_modules is not None else source_modules emitted_modules: dict[str, SemanticModule] = {} for module in source_modules: if module.name in emitted_modules: @@ -124,12 +126,24 @@ def emit_module_stubs( for dependency in opaque_dependency_modules( source_modules, - available_modules=available_modules, + available_modules=available, ): target = emitted_modules.setdefault(dependency.name, SemanticModule(name=dependency.name)) existing = {cls.name for cls in target.classes} target.classes.extend(cls for cls in dependency.classes if cls.name not in existing) + # Public names are owned by post-IR policy for every route, so they are + # completed even where the rest of policy cannot run: a C starter contract + # describes source the direct-only wrapper may go on to reject, and naming + # a declaration does not depend on whether that declaration is buildable. + # Available modules also participate in this naming pass. They are not + # emitted, but an emitted module importing one must ask for the exact name + # its separately emitted contract declares. + naming_modules = dict(emitted_modules) + for module in available: + naming_modules.setdefault(module.name, deepcopy(module)) + for module in naming_modules.values(): + complete_python_export_policy(module) 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 @@ -138,11 +152,11 @@ def emit_module_stubs( # imported; either way a contract reading from it names it that way. declared_prototype_names = { (module_name, str(prototype.name)) - for module_name, module in emitted_modules.items() + for module_name, module in naming_modules.items() for prototype in module.prototypes } | { (module_name, str(reexport.local_name)) - for module_name, module in emitted_modules.items() + for module_name, module in naming_modules.items() for reexport in module.reexports if reexport.entity_kind == "prototype" } @@ -150,7 +164,7 @@ def emit_module_stubs( # every module is named once before any of them writes an import. naming_printer = PyiPrinter(normalize_public_names=normalize_public_names) published_names_by_module = { - module_name: naming_printer.published_names(module) for module_name, module in emitted_modules.items() + module_name: naming_printer.published_names(module) for module_name, module in naming_modules.items() } return { module_name: emit_module( diff --git a/prik/planning/planner.py b/prik/planning/planner.py index aeee2118e..af49d0083 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -72,7 +72,6 @@ completed_module_variable_policy, ) from prik.naming.generated_files import bridge_source_name -from prik.naming.policy import normalize_public_name, preserves_source_case from prik.policy.exports import PythonExportPolicy from prik.policy.ownership import AssignmentMode, NativeBarrierAction, SetterAction from prik.planning.models import ( @@ -552,10 +551,11 @@ def _aliases_by_namespace(self, module: models.SemanticModule) -> dict[tuple[str 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. + knows which name the declaring namespace actually bound. The alias + publishes under the name export policy completed for it, inside the same + ledger as this module's declarations, so it cannot take one of theirs. """ grouped = defaultdict(list) - preserve_case = preserves_source_case(module.origin.source_language) for reexport in module.reexports: if reexport.entity_kind not in _ALIASABLE_REEXPORT_KINDS: continue @@ -565,7 +565,7 @@ def _aliases_by_namespace(self, module: models.SemanticModule) -> dict[tuple[str continue grouped[tuple(part.casefold() for part in reexport.module.split(".") if part)].append( NamespaceAliasPlan( - python_name=normalize_public_name(reexport.local_name, preserve_case=preserve_case).name, + python_name=reexport.python_name or str(reexport.local_name), source_namespace=source_namespace, source_name=source_name, ) diff --git a/prik/policy/exports.py b/prik/policy/exports.py index 6ed134a57..15ca96cbc 100644 --- a/prik/policy/exports.py +++ b/prik/policy/exports.py @@ -17,6 +17,8 @@ from prik.naming import NamingPolicy, normalize_public_name, preserves_source_case from prik.semantics import models +from prik.semantics.pyi_metadata import PYI_LOADED_METADATA +from prik.semantics.models import export_namespace @dataclass(frozen=True) @@ -32,10 +34,17 @@ def complete_python_export_policy( *, strict_wrapper_names: bool = False, ) -> None: - """Resolve every public export name within its owning Python namespace.""" + """Resolve every public export name within its owning Python namespace. + + A module read from a semantic ``.pyi`` is already named in Python -- the + contract states the names it publishes -- so those spellings are kept + exactly. Only a module converted from native source has names PRIK must + choose, and only where the source language has no spelling of its own. + """ + contract_named = bool(module.metadata.get(PYI_LOADED_METADATA)) naming = NamingPolicy( strict_public_names=strict_wrapper_names, - preserve_case=preserves_source_case(module.origin.source_language), + preserve_case=contract_named or preserves_source_case(module.origin.source_language), ) for owner in _module_export_owners(module): if getattr(owner, "visibility", "public") == "private": @@ -47,16 +56,56 @@ def complete_python_export_policy( metadata[models.PYTHON_EXPORTS_METADATA] = exports category = _owner_category(owner) for export in exports: - namespace = _export_namespace(export) + namespace = export_namespace(export) raw_name = owner.name if export.get("name") is None else export["name"] resolved_name = naming.reserve_public_name( namespace, raw_name, - category=category, + category="function" if contract_named else category, owner=f"{category} {owner.name}", ) if export.get("name") is None: export["name"] = resolved_name + _complete_reexport_names(module, naming, contract_named=contract_named) + + +def _complete_reexport_names( + module: models.SemanticModule, + naming: NamingPolicy, + *, + contract_named: bool, +) -> None: + """Name each re-export in the namespace that publishes it. + + A re-export adds no declaration, but it does add a Python attribute, so it + competes for a name with everything the publishing module declares. It is + reserved after those declarations: a module's own declaration keeps the + name it would have had, and an imported alias is the one moved aside. + """ + for reexport in module.reexports: + if reexport.python_name: + continue + category = "class" if reexport.entity_kind == "derived_type" else "function" + reexport.python_name = naming.reserve_public_name( + _reexport_namespace(module, reexport), + reexport.local_name, + category="function" if contract_named else category, + owner=f"re-export {reexport.local_name}", + ) + + +def _reexport_namespace(module: models.SemanticModule, reexport: models.SemanticReexport) -> tuple[str, ...]: + """Return the Python namespace one re-export publishes into. + + A re-export names the module publishing it. Completing that same module + names it against the module's own root, which is where its declarations + are; completing a merged package instead names it inside the namespace + that module occupies there, beside the declarations it sits with. + """ + publisher = str(reexport.module or "") + if not publisher or publisher.casefold() == str(module.name).casefold(): + return () + return tuple(part.casefold() for part in publisher.split(".") if part) def _module_export_owners(module: models.SemanticModule): @@ -80,14 +129,6 @@ def _owner_category(owner) -> str: return "function" -def _export_namespace(export: dict[str, object]) -> tuple[str, ...]: - """Return one normalized namespace tuple from semantic export metadata.""" - raw_namespace = export.get("namespace", ()) - if not isinstance(raw_namespace, tuple | list): - return () - return tuple(str(part) for part in raw_namespace) - - def completed_python_exports( owner: models.SemanticFunction | models.SemanticVariable, default_name: str, @@ -105,13 +146,17 @@ def completed_python_exports( ) exports.append( PythonExportPolicy( - namespace=_export_namespace(item), + namespace=export_namespace(item), name=str(name), ) ) if not exports and getattr(owner, "visibility", "public") != "private": - preserve_case = preserves_source_case(owner.origin.source_language) - exports.append(PythonExportPolicy((), normalize_public_name(default_name, preserve_case=preserve_case).name)) + fallback = normalize_public_name( + default_name, + preserve_case=preserves_source_case(owner.origin.source_language), + category=_owner_category(owner), + ) + exports.append(PythonExportPolicy((), fallback.name)) return tuple(dict.fromkeys(exports)) diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index 819dccc17..1fe4291d3 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -46,6 +46,8 @@ OVERLOAD_TARGET_METADATA, NATIVE_BY_VALUE_METADATA, PYTHON_BOUND_POSITION_METADATA, + PYTHON_EXPORTS_METADATA, + export_namespace, PYTHON_METHOD_NAME_METADATA, PYTHON_STATIC_METADATA, PYTHON_VALUE_IMMUTABLE, @@ -68,6 +70,7 @@ SemanticImportItem, SemanticMethod, SemanticModule, + SemanticReexport, SemanticPrototype, SemanticStorageContract, SemanticType, @@ -96,6 +99,26 @@ class _PyiEmissionContext: naming_policy: NamingPolicy = field(default_factory=NamingPolicy) reserved_public_names: dict[tuple[tuple[str, ...], str, object], str] = field(default_factory=dict) public_namespace: tuple[str, ...] = () + reexport_names: dict[str, str] = field(default_factory=dict) + """Each re-exported source name, casefolded, to the name policy completed. + + An import binding a re-exported name writes what export policy settled, so + the import cannot bind a name one of this module's declarations holds. + """ + class_python_names: dict[str, str] = field(default_factory=dict) + """Each wrapped type's source name to the spelling this contract declares it under. + + A declaration and every annotation naming it read the same entry, so an + annotation cannot refer to a class the contract never declares. + """ + settled_names: dict[tuple[str, str], str] = field(default_factory=dict) + """Module-level names post-IR policy completed, keyed by category and source name. + + Policy owns every public name a build publishes, and a contract describing + that build states the same ones. The emission reads them from here rather + than allocating a second set, whose ordering and collision suffixes would + be its own and could attach the same names to different declarations. + """ published_names: dict[str, str] = field(default_factory=dict) """Source name, casefolded, to the spelling this contract published it under. @@ -115,7 +138,7 @@ def contract_type(self, name: str) -> str: """Return the local spelling for one contract type name.""" if name in CONTRACT_TYPE_NAMES: return self.contract(name) - return name + return self.class_python_names.get(str(name), name) def inside_class(self, name: str) -> _PyiEmissionContext: """Return a child namespace view sharing this emission's accumulators.""" @@ -134,6 +157,20 @@ def public_name(self, raw_name: str, *, category: str, owner: object) -> str: owner=raw_name, ) self.reserved_public_names[key] = public_name + return self.publish(raw_name, public_name) + + def settled(self, category: str, raw_name: object) -> str | None: + """Return the completed name for one module-level declaration, if any. + + A class member is named by class-surface policy, which only a build + request completes, so inside a class there is nothing to read here. + """ + if self.public_namespace: + return None + return self.settled_names.get((category, str(raw_name))) + + def publish(self, raw_name: object, public_name: str) -> str: + """Record the spelling this contract published one name under.""" if not self.public_namespace: self.published_names.setdefault(str(raw_name).casefold(), public_name) return public_name @@ -241,12 +278,34 @@ def _emission_context(self, node) -> _PyiEmissionContext: return _PyiEmissionContext( normalize_public_names=self._normalize_public_names, ) - # Naming is decided by prik.naming for every stage; the emission only - # tells it which language the declarations were written in. + # Post-IR policy owns every module-level public name; the emission reads + # them. The allocator below names only what policy does not reach: the + # members of a class, whose names class-surface policy completes for a + # build request alone. naming_policy = NamingPolicy(preserve_case=preserves_source_case(node.origin.source_language)) + settled_names = self._completed_public_names(node) + for (category, _source_name), public_name in settled_names.items(): + # Hold every completed name in the allocator as well, so a + # declaration policy never named -- a private one, which no build + # publishes -- cannot be handed a name that is already spoken for. + naming_policy.reserve_public_name((), public_name, category=category, owner=public_name) return _PyiEmissionContext( normalize_public_names=self._normalize_public_names, naming_policy=naming_policy, + settled_names=settled_names, + # A contract read back from .pyi keeps every spelling verbatim, so + # there is nothing to map and both the declaration and every + # annotation naming it fall through to the source name. + class_python_names=( + {str(cls.name): settled_names.get(("class", str(cls.name)), str(cls.name)) for cls in node.classes} + if self._normalize_public_names + else {} + ), + reexport_names={ + str(reexport.local_name).casefold(): reexport.python_name + for reexport in node.reexports + if reexport.python_name + }, default_array_order=self._native_default_array_order(node.origin.source_language), semantic_class_names=frozenset( str(cls.name) @@ -256,6 +315,44 @@ def _emission_context(self, node) -> _PyiEmissionContext: contract_aliases=self._contract_aliases_for_module(node), ) + @staticmethod + def _completed_public_names(module: SemanticModule) -> dict[tuple[str, str], str]: + """Return the module-level names post-IR policy completed. + + The categories and the metadata location match + ``prik.policy.exports``, which is the owner: an overload set records + its export on its first procedure. Only an export that publishes a + declaration as this module's own names what is written here -- a build + gives a Fortran module's members that module's namespace and a + standalone procedure the root one, while a re-export elsewhere names a + different namespace and is not what this file declares. + """ + own_namespaces = {(), (str(module.name).casefold(),)} + owners = ( + *((cls, "class") for cls in module.classes), + *((func, "function") for func in module.functions), + *((overload_set, "function") for overload_set in module.overload_sets), + *((variable, "variable") for variable in module.variables), + ) + settled: dict[tuple[str, str], str] = {} + for owner, category in owners: + for export in PyiPrinter._owner_exports(owner): + namespace = tuple(part.casefold() for part in export_namespace(export)) + if export.get("name") is None or namespace not in own_namespaces: + continue + settled[(category, str(owner.name))] = str(export["name"]) + break + return settled + + @staticmethod + def _owner_exports(owner: object) -> tuple[dict, ...]: + """Return one declaration's completed Python export records.""" + if isinstance(owner, ProcedureOverloadSet): + metadata = owner.procedures[0].metadata if owner.procedures else {} + else: + metadata = getattr(owner, "metadata", {}) or {} + return tuple(item for item in metadata.get(PYTHON_EXPORTS_METADATA, ()) or () if isinstance(item, dict)) + @staticmethod def _visit_not_supported(node): """Reject semantic models that have no `.pyi` visitor.""" @@ -468,7 +565,7 @@ def _visit_ProcedureOverloadSet( ) indent = " " else: - candidate.name = overload_set.name + candidate.name = self._overload_set_name(overload_set, context) definition = self._emit_function( candidate, context, @@ -510,7 +607,8 @@ def _visit_SemanticClass( if cls.base_classes else "" ) - body = self._class_body(cls, context.inside_class(cls.name)) + emitted_name = self._class_name(cls, context) + body = self._class_body(cls, context.inside_class(emitted_name)) decorators = [] if self._is_private(cls): decorators.append(f"@{context.contract('private')}") @@ -524,14 +622,14 @@ def _visit_SemanticClass( if ( cls.origin.source_language == "fortran" and cls.native_name - and self._renames_native_entity(cls, cls.native_name, cls.name) + and self._renames_native_entity(cls, cls.native_name, emitted_name) ): decorators.append(f"@{context.contract('bind')}({json.dumps(str(cls.native_name))})") decorator_text = "\n".join(decorators) if decorator_text: decorator_text += "\n" return f""" -{decorator_text}class {cls.name}{bases}: +{decorator_text}class {emitted_name}{bases}: {body} """.strip() @@ -637,7 +735,7 @@ def _module_exported_names( names: list[str] = [] for semantic_class in self._contract_items(module.classes): if not self._is_private(semantic_class): - names.append(semantic_class.name) + names.append(self._class_name(semantic_class, context)) names.extend(str(prototype.name) for prototype in module.prototypes) for variable in self._contract_items(module.variables): if getattr(variable, "visibility", "public") != "private": @@ -645,7 +743,7 @@ def _module_exported_names( for function in self._contract_items(module.functions, keep_names=overload_targets): if not self._is_private(function): names.append(self._callable_name(function, context)) - names.extend(str(overload_set.name) for overload_set in module.overload_sets) + names.extend(self._overload_set_name(overload_set, context) for overload_set in module.overload_sets) for reexport in module.reexports: if self._is_source_kind_import(str(reexport.origin_module)): continue @@ -657,9 +755,10 @@ def _module_exported_names( # A prototype keeps its declared spelling wherever it is written, so # the name published for it is the one its import binds. local = str(reexport.local_name) - names.append( - local if reexport.entity_kind == "prototype" else self._public_import_name(local, public_names=True) - ) + if reexport.entity_kind == "prototype": + names.append(local) + continue + names.append(self._reexport_name(reexport, context)) return list(dict.fromkeys(names)) # ------------------------------------------------------------------ @@ -1605,6 +1704,7 @@ def _append_imports( public_names=context.normalize_public_names, verbatim_names=verbatim, published_names_by_module=self._published_names_by_module, + reexport_names=context.reexport_names, ) ) if contract_import or imports: @@ -1940,6 +2040,7 @@ def _emit_import( public_names: bool = False, verbatim_names: dict[tuple[str, str], str] | None = None, published_names_by_module: dict[str, dict[str, str]] | None = None, + reexport_names: dict[str, str] | None = None, ) -> str: """Emit import syntax.""" if isinstance(imp, str): @@ -1955,6 +2056,7 @@ def _emit_import( verbatim_names=verbatim_names, source_module=source_module, published_names=published_names, + reexport_names=reexport_names, ) for item in imp.items ) @@ -1969,6 +2071,7 @@ def _emit_import_item( verbatim_names: dict[tuple[str, str], str] | None = None, source_module: str = "", published_names: dict[str, str] | None = None, + reexport_names: dict[str, str] | None = None, ) -> str: """Emit import item syntax. @@ -1999,7 +2102,11 @@ def _emit_import_item( source = PyiPrinter._public_import_name(item.source, public_names=public_names) if public_names and published_names: source = published_names.get(item.source.casefold(), source) - bound = PyiPrinter._public_import_name(local, public_names=public_names) + # A name this module publishes is bound under the name export policy + # completed for it, which a collision with one of this module's own + # declarations may have moved aside. + bound = (reexport_names or {}).get(local.casefold()) if public_names else None + bound = bound or PyiPrinter._public_import_name(local, public_names=public_names) if bound and bound != source: return f"{source} as {bound}" return source @@ -2315,12 +2422,44 @@ def _callable_name( """Return the Python-visible callable name to write in the contract.""" if not context.normalize_public_names or func.name.startswith("__"): return func.name + settled = context.settled("function", func.name) + if settled is not None: + return context.publish(func.name, settled) return context.public_name( func.name, category="method" if isinstance(func, SemanticMethod) else "function", owner=owner if owner is not None else func, ) + @staticmethod + def _reexport_name(reexport: SemanticReexport, context: _PyiEmissionContext) -> str: + """Return the Python name this contract publishes one re-export under. + + Export policy names a re-export in the same ledger as the module's own + declarations, so the contract states what it completed rather than a + spelling derived here, which could take a name a declaration holds. + """ + local = str(reexport.local_name) + if not context.normalize_public_names: + return local + return context.publish(local, reexport.python_name or local) + + @staticmethod + def _class_name(cls: SemanticClass, context: _PyiEmissionContext) -> str: + """Return the Python-visible class name to write in the contract.""" + emitted = context.class_python_names.get(str(cls.name), str(cls.name)) + return context.publish(cls.name, emitted) + + @staticmethod + def _overload_set_name(overload_set: ProcedureOverloadSet, context: _PyiEmissionContext) -> str: + """Return the Python-visible name of one module-level overload set.""" + if not context.normalize_public_names: + return str(overload_set.name) + settled = context.settled("function", overload_set.name) + if settled is not None: + return context.publish(overload_set.name, settled) + return context.public_name(overload_set.name, category="function", owner=overload_set) + @staticmethod def _data_member_name( variable: SemanticVariable, @@ -2339,6 +2478,9 @@ def _module_variable_name( """Return the Python-visible module variable name.""" if not context.normalize_public_names: return variable.name + settled = context.settled("variable", variable.name) + if settled is not None: + return context.publish(variable.name, settled) return context.public_name(variable.name, category="variable", owner=variable) def _decorators( diff --git a/prik/semantics/models.py b/prik/semantics/models.py index f3004941b..3e6b91bfe 100644 --- a/prik/semantics/models.py +++ b/prik/semantics/models.py @@ -401,6 +401,16 @@ class ProcedureOverloadSet: PYTHON_BOUND_POSITION_METADATA = "python_bound_position" PYTHON_METHOD_NAME_METADATA = "python_method_name" PYTHON_EXPORTS_METADATA = "python_exports" + + +def export_namespace(export: dict[str, object]) -> tuple[str, ...]: + """Return one normalized namespace tuple from semantic export metadata.""" + raw_namespace = export.get("namespace", ()) + if not isinstance(raw_namespace, tuple | list): + return () + return tuple(str(part) for part in raw_namespace) + + PYTHON_EXPORTS_PREPARED_METADATA = "python_exports_prepared" POLICY_COMPLETION_PREPARED_METADATA = "policy_completion_prepared" HIDDEN_NATIVE_OUTPUT_METADATA = "hidden_native_output" @@ -686,6 +696,14 @@ class SemanticReexport: module: str = "" """Module publishing the name, which is not the one declaring it.""" + python_name: str = "" + """The Python name this module publishes the re-export under. + + Post-IR export policy completes it inside the same namespace ledger as the + module's own declarations, so an alias cannot be given a name a declaration + already holds. Every later stage reads it rather than deriving one. + """ + entity_kind: str = "unknown" """What the published name declares where it comes from. diff --git a/tests/fortran/_support/wrapper_build.py b/tests/fortran/_support/wrapper_build.py index 3cc11d43d..560a0359c 100644 --- a/tests/fortran/_support/wrapper_build.py +++ b/tests/fortran/_support/wrapper_build.py @@ -575,8 +575,8 @@ def _assert_modern_string_examples(module): def _assert_modern_class_examples(module): - assert hasattr(module, "vector") - value = module.vector() + assert hasattr(module, "Vector") + value = module.Vector() value.x = np.float64(3.0) value.y = np.float64(4.0) @@ -592,8 +592,8 @@ def _assert_modern_class_examples(module): assert value.x == np.float64(3.75) assert value.y == np.float64(3.0) - assert hasattr(module, "vector_store") - store = module.vector_store() + assert hasattr(module, "Vector_Store") + store = module.Vector_Store() values = store.values matrix_values = store.matrix assert isinstance(values, AllocatableArray) @@ -649,7 +649,7 @@ def _assert_modern_class_examples(module): with pytest.raises(TypeError, match=r"expected ordering \(F\)"): store.set_matrix(np.array(replacement, order="C")) - made = module.vector_store.make(np.int64(4), np.float64(1.5)) + made = module.Vector_Store.make(np.int64(4), np.float64(1.5)) made_values = made.values assert isinstance(made_values, AllocatableArray) assert made_values.owner is made diff --git a/tests/fortran/allocatables/end_to_end/fixtures/contracts/fallocatable_views_f90/fallocatable_views_f90.pyi b/tests/fortran/allocatables/end_to_end/fixtures/contracts/fallocatable_views_f90/fallocatable_views_f90.pyi index 027a458dd..8609d97d9 100644 --- a/tests/fortran/allocatables/end_to_end/fixtures/contracts/fallocatable_views_f90/fallocatable_views_f90.pyi +++ b/tests/fortran/allocatables/end_to_end/fixtures/contracts/fallocatable_views_f90/fallocatable_views_f90.pyi @@ -1,6 +1,6 @@ from prik.contracts import Addr, Aliased, Allocatable, Annotated, Arg, Float64, Int32, Pass, Return, Returns, native_call -class buffer: +class Buffer: def __init__(self) -> None: ... values: Allocatable[Float64[:]] @@ -84,7 +84,7 @@ def make_matrix( ) -> Allocatable[Float64[:, :]]: ... __all__ = [ - "buffer", + "Buffer", "module_values", "allocate_module_values", "deallocate_module_values", diff --git a/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py b/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py index 025e9eb87..db8873ca6 100644 --- a/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py +++ b/tests/fortran/allocatables/end_to_end/test_allocatable_handles.py @@ -165,15 +165,15 @@ def test_allocatable_module_fields_and_results_expose_lifetime_safe_handles( assert "Persistent allocatable descriptor handle." in module.__doc__ assert "Replacement assignment is not supported." in module.__doc__ assert "build_values" in module.__doc__ - assert "buffer" in module.__doc__ + assert "Buffer" in module.__doc__ assert "build_values(n) -> AllocatableArray[float64]" in module.build_values.__doc__ assert "values : AllocatableArray[float64]" in module.build_values.__doc__ assert "Descriptor ownership: owned" in module.build_values.__doc__ assert "Unallocated state remains inside the returned handle." in module.build_values.__doc__ assert not hasattr(module, "get_module_values") - assert "Fields" in module.buffer.__doc__ - assert "values : AllocatableArray[float64]" in module.buffer.__doc__ - assert "allocatable array descriptor handle" in module.buffer.values.__doc__ + assert "Fields" in module.Buffer.__doc__ + assert "values : AllocatableArray[float64]" in module.Buffer.__doc__ + assert "allocatable array descriptor handle" in module.Buffer.values.__doc__ module_values = module.module_values assert isinstance(module_values, AllocatableArray) @@ -268,7 +268,7 @@ def test_allocatable_module_fields_and_results_expose_lifetime_safe_handles( gc.collect() np.testing.assert_allclose(retained_result_view, np.array([3.0, 6.0, 9.0], dtype=np.float64)) - values = module.buffer() + values = module.Buffer() field_handle = values.values assert isinstance(field_handle, AllocatableArray) assert field_handle.owner is values diff --git a/tests/fortran/callbacks/codegen/test_callback_planning.py b/tests/fortran/callbacks/codegen/test_callback_planning.py index 740b36bb0..1077eb750 100644 --- a/tests/fortran/callbacks/codegen/test_callback_planning.py +++ b/tests/fortran/callbacks/codegen/test_callback_planning.py @@ -86,7 +86,7 @@ def test_callback_policy_completes_value_default_and_explicit_reference_before_p assert tuple(transfer.character_length for transfer in string.arguments) == (8, 8, 8) derived = policies["apply_point_callback"].arguments[0].callback - assert derived.arguments[0].derived_type_identity == ("fcallback_all_f90", "point_t") + assert derived.arguments[0].derived_type_identity == ("fcallback_all_f90", "Point_T") assert derived.result.action is CallbackResultAction.RETURN_DERIVED_ADDRESS 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 1d2349d17..dfd94bd7b 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 @@ -1,6 +1,6 @@ from prik.contracts import Addr, Arg, Float64, In, InOut, Int32, Out, Return, Returns, String, native_call, prototype -class point_t: +class Point_T: def __init__( self, *, @@ -39,8 +39,8 @@ def string_storage_callback( @prototype def point_callback( - value: In(point_t) -) -> point_t: ... + value: In(Point_T) +) -> Point_T: ... @native_call([Arg(0), Addr(Arg(1))]) def apply_value_callback( @@ -71,12 +71,12 @@ def apply_string_storage_callback( def apply_point_callback( callback: point_callback, - value: point_t, - output: point_t + value: Point_T, + output: Point_T ) -> None: ... __all__ = [ - "point_t", + "Point_T", "value_callback", "scalar_storage_callback", "array_storage_callback", 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 98c14bccb..6cdaf8e66 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 @@ -239,7 +239,7 @@ def test_imported_callback_returning_a_module_owned_type_builds(tmp_path: Path): declaring = (contracts / "cbresult_types.pyi").read_text(encoding="utf-8") assert "def make_point(" in declaring - assert "-> point_t: ..." in declaring + assert "-> Point_T: ..." in declaring result = build_pyi_extension( contracts / "__init__.pyi", diff --git a/tests/fortran/callbacks/end_to_end/test_supported_callback_shapes.py b/tests/fortran/callbacks/end_to_end/test_supported_callback_shapes.py index 361754856..b1173f4eb 100644 --- a/tests/fortran/callbacks/end_to_end/test_supported_callback_shapes.py +++ b/tests/fortran/callbacks/end_to_end/test_supported_callback_shapes.py @@ -63,11 +63,11 @@ def string_callback(read_label, write_label, update_label): assert module.apply_string_storage_callback(string_callback, "OLD ") == ("UPDATED!", "WRITTEN!") - point = module.point_t(x=np.float64(2.0), y=np.float64(5.0)) - shifted = module.point_t() + point = module.Point_T(x=np.float64(2.0), y=np.float64(5.0)) + shifted = module.Point_T() assert ( module.apply_point_callback( - lambda value: module.point_t(x=value.x + 1.0, y=value.y * 2.0), + lambda value: module.Point_T(x=value.x + 1.0, y=value.y * 2.0), point, shifted, ) diff --git a/tests/fortran/derived_types/codegen/test_class_surfaces.py b/tests/fortran/derived_types/codegen/test_class_surfaces.py index 43b0ef678..ed3714fec 100644 --- a/tests/fortran/derived_types/codegen/test_class_surfaces.py +++ b/tests/fortran/derived_types/codegen/test_class_surfaces.py @@ -27,8 +27,8 @@ def _surface(plan, name: str): def test_inheritance_and_polymorphism_are_completed_before_planning(): plan = _plan(INHERITANCE) - base = _surface(plan, "base_shape") - circle = _surface(plan, "circle") + base = _surface(plan, "Base_Shape") + circle = _surface(plan, "Circle") derived = next( item for namespace in plan.namespaces @@ -45,15 +45,15 @@ def test_inheritance_and_polymorphism_are_completed_before_planning(): assert circle.base_identities == (base.type_identity,) assert [field.name for field in derived.fields] == ["size", "radius"] assert tuple(variant.python_name for variant in describe.arguments[0].polymorphic.variants) == ( - "box", - "circle", - "base_shape", + "Box", + "Circle", + "Base_Shape", ) def test_invalid_class_graph_fails_before_emission(): plan = _plan(INHERITANCE) - _surface(plan, "circle").base_identities = (("missing", "base"),) + _surface(plan, "Circle").base_identities = (("missing", "base"),) with pytest.raises(ValueError, match="missing-or-late-class-base"): WrapperGenerator().generate(plan) diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fbind_c_derived_layout_f90/fbind_c_derived_layout_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fbind_c_derived_layout_f90/fbind_c_derived_layout_f90.pyi index e1ae5bc15..e6d0d4e18 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fbind_c_derived_layout_f90/fbind_c_derived_layout_f90.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fbind_c_derived_layout_f90/fbind_c_derived_layout_f90.pyi @@ -1,7 +1,7 @@ from prik.contracts import Arg, Complex128, Float64, Int32, Value, native_abi, native_call @native_abi("c") -class point: +class Point: def __init__( self, *, @@ -13,19 +13,19 @@ class point: axis: Int32 @native_abi("c") -class tagged_point: +class Tagged_Point: def __init__( self, *, weight: Complex128 = ... ) -> None: ... - position: point + position: Point weight: Complex128 @native_abi("c") def populate( - value: tagged_point, + value: Tagged_Point, x: Float64, axis: Int32, weight: Complex128 @@ -34,7 +34,7 @@ def populate( @native_abi("c") @native_call([Value(Arg(0))]) def score_by_value( - value: tagged_point + value: Tagged_Point ) -> Float64: ... -__all__ = ["point", "tagged_point", "populate", "score_by_value"] +__all__ = ["Point", "Tagged_Point", "populate", "score_by_value"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fborrowed_finalizer_f90/fborrowed_finalizer_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fborrowed_finalizer_f90/fborrowed_finalizer_f90.pyi index b4d583676..34536eafb 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fborrowed_finalizer_f90/fborrowed_finalizer_f90.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fborrowed_finalizer_f90/fborrowed_finalizer_f90.pyi @@ -1,16 +1,16 @@ from prik.contracts import Int32, destroy -class child: +class Child: @destroy def cleanup_child(self) -> None: ... -class parent: +class Parent: def __init__(self) -> None: ... - value: child + value: Child def get_final_count() -> Int32: ... def reset_final_count() -> None: ... -__all__ = ["child", "parent", "get_final_count", "reset_final_count"] +__all__ = ["Child", "Parent", "get_final_count", "reset_final_count"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fclasses_f90/fclasses_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fclasses_f90/fclasses_f90.pyi index 0d5a7f68f..5eef0d5b3 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fclasses_f90/fclasses_f90.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fclasses_f90/fclasses_f90.pyi @@ -1,6 +1,6 @@ from prik.contracts import Addr, Allocatable, Annotated, Arg, Float64, Int64, Pass, Polymorphic, bind, native_call -class vector: +class Vector: def __init__( self, *, @@ -27,7 +27,7 @@ class vector: def magnitude(self) -> Float64: ... -class vector_store: +class Vector_Store: def __init__(self) -> None: ... values: Allocatable[Float64[:]] @@ -62,45 +62,45 @@ class vector_store: def make( n: Int64, fill_value: Float64 - ) -> vector_store: ... + ) -> Vector_Store: ... @native_call([Arg(0), Addr(Arg(1))]) def scale( - self: Annotated[vector, Polymorphic], + self: Annotated[Vector, Polymorphic], factor: Float64 ) -> None: ... @native_call([Addr(Arg(0)), Arg(1), Addr(Arg(2))]) def shift_vector( dx: Float64, - owner: Annotated[vector, Polymorphic], + owner: Annotated[Vector, Polymorphic], dy: Float64 ) -> None: ... def magnitude( - self: Annotated[vector, Polymorphic] + self: Annotated[Vector, Polymorphic] ) -> Float64: ... @native_call([Arg(0), Addr(Arg(1))]) def allocate_values( - self: Annotated[vector_store, Polymorphic], + self: Annotated[Vector_Store, Polymorphic], n: Int64 ) -> None: ... def set_values( - self: Annotated[vector_store, Polymorphic], + self: Annotated[Vector_Store, Polymorphic], source: Float64[::] ) -> None: ... @native_call([Arg(0), Addr(Arg(1)), Addr(Arg(2))]) def allocate_matrix( - self: Annotated[vector_store, Polymorphic], + self: Annotated[Vector_Store, Polymorphic], rows: Int64, cols: Int64 ) -> None: ... def set_matrix( - self: Annotated[vector_store, Polymorphic], + self: Annotated[Vector_Store, Polymorphic], source: Float64[::, ::] ) -> None: ... @@ -108,11 +108,11 @@ def set_matrix( def make_vector_store( n: Int64, fill_value: Float64 -) -> vector_store: ... +) -> Vector_Store: ... __all__ = [ - "vector", - "vector_store", + "Vector", + "Vector_Store", "scale", "shift_vector", "magnitude", diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fconstructors_f90/fconstructors_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fconstructors_f90/fconstructors_f90.pyi index b748e70d9..ad28c4c5c 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fconstructors_f90/fconstructors_f90.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fconstructors_f90/fconstructors_f90.pyi @@ -1,6 +1,6 @@ from prik.contracts import Float64, Int32, destroy -class initialized: +class Initialized: def __init__( self, *, @@ -18,4 +18,4 @@ def get_final_count() -> Int32: ... def reset_final_count() -> None: ... -__all__ = ["initialized", "get_final_count", "reset_final_count"] +__all__ = ["Initialized", "get_final_count", "reset_final_count"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fderived_boundary_f90/fderived_boundary_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fderived_boundary_f90/fderived_boundary_f90.pyi index 4efc42d0c..89880a24d 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fderived_boundary_f90/fderived_boundary_f90.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fderived_boundary_f90/fderived_boundary_f90.pyi @@ -1,6 +1,6 @@ from prik.contracts import Addr, Arg, Float64, native_call -class point: +class Point: def __init__( self, *, @@ -11,30 +11,30 @@ class point: x: Float64 y: Float64 -class holder: +class Holder: def __init__( self, *, scale: Float64 = ... ) -> None: ... - origin: point + origin: Point scale: Float64 def point_sum( - p: point + p: Point ) -> Float64: ... @native_call([Arg(0), Addr(Arg(1)), Addr(Arg(2))]) def move_point( - p: point, + p: Point, dx: Float64, dy: Float64 ) -> None: ... @native_call([Arg(0), Addr(Arg(1)), Addr(Arg(2))]) def make_point_out( - p: point, + p: Point, x: Float64, y: Float64 ) -> None: ... @@ -43,20 +43,20 @@ def make_point_out( def make_point( x: Float64, y: Float64 -) -> point: ... +) -> Point: ... def set_holder_origin( - h: holder, - p: point + h: Holder, + p: Point ) -> None: ... def holder_origin_x( - h: holder + h: Holder ) -> Float64: ... __all__ = [ - "point", - "holder", + "Point", + "Holder", "point_sum", "move_point", "make_point_out", diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/finheritance_f90/finheritance_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/finheritance_f90/finheritance_f90.pyi index 0ff445b6f..9f0a8fb02 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/finheritance_f90/finheritance_f90.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/finheritance_f90/finheritance_f90.pyi @@ -1,6 +1,6 @@ from prik.contracts import Addr, Annotated, Arg, Float64, Pass, Polymorphic, bind, native_call -class base_shape: +class Base_Shape: def __init__( self, *, @@ -19,7 +19,7 @@ class base_shape: value: Float64 ) -> None: ... -class circle(base_shape): +class Circle(Base_Shape): def __init__( self, *, @@ -31,7 +31,7 @@ class circle(base_shape): @bind("circle_area") def area(self) -> Float64: ... -class box(base_shape): +class Box(Base_Shape): def __init__( self, *, @@ -44,25 +44,25 @@ class box(base_shape): def area(self) -> Float64: ... def base_area( - self: Annotated[base_shape, Polymorphic] + self: Annotated[Base_Shape, Polymorphic] ) -> Float64: ... @native_call([Arg(0), Addr(Arg(1))]) def base_set_size( - self: Annotated[base_shape, Polymorphic], + self: Annotated[Base_Shape, Polymorphic], value: Float64 ) -> None: ... def circle_area( - self: Annotated[circle, Polymorphic] + self: Annotated[Circle, Polymorphic] ) -> Float64: ... def box_area( - self: Annotated[box, Polymorphic] + self: Annotated[Box, Polymorphic] ) -> Float64: ... def describe_shape( - item: Annotated[base_shape, Polymorphic] + item: Annotated[Base_Shape, Polymorphic] ) -> Float64: ... -__all__ = ["base_shape", "circle", "box", "base_area", "base_set_size", "circle_area", "box_area", "describe_shape"] +__all__ = ["Base_Shape", "Circle", "Box", "base_area", "base_set_size", "circle_area", "box_area", "describe_shape"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fmodule_derived_alias_f90/fmodule_derived_alias_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fmodule_derived_alias_f90/fmodule_derived_alias_f90.pyi index 32481ec54..634834466 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/contracts/fmodule_derived_alias_f90/fmodule_derived_alias_f90.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/contracts/fmodule_derived_alias_f90/fmodule_derived_alias_f90.pyi @@ -1,6 +1,6 @@ from prik.contracts import Addr, Aliased, Allocatable, Annotated, Arg, Float64, Int32, Pass, native_call -class box: +class Box: def __init__(self) -> None: ... values: Allocatable[Float64[:]] @@ -13,7 +13,7 @@ class box: def values_sum(self) -> Float64: ... -current: Annotated[box, Aliased] +current: Annotated[Box, Aliased] @native_call([Addr(Arg(0))]) def allocate_current( @@ -24,4 +24,4 @@ def deallocate_current() -> None: ... def current_sum() -> Float64: ... -__all__ = ["box", "current", "allocate_current", "deallocate_current", "current_sum"] +__all__ = ["Box", "current", "allocate_current", "deallocate_current", "current_sum"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_direct_bind_c_f90/derived_types_direct_bind_c_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_direct_bind_c_f90/derived_types_direct_bind_c_f90.pyi index bd6c39f8a..ea96225ca 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_direct_bind_c_f90/derived_types_direct_bind_c_f90.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_direct_bind_c_f90/derived_types_direct_bind_c_f90.pyi @@ -1,7 +1,7 @@ from prik.contracts import Float64, native_abi @native_abi("c") -class point: +class Point: def __init__( self, *, @@ -14,13 +14,13 @@ class point: @native_abi("c") def direct_sum( - value: point + value: Point ) -> Float64: ... @native_abi("c") def direct_shift( - value: point, + value: Point, delta: Float64 ) -> None: ... -__all__ = ["point", "direct_sum", "direct_shift"] +__all__ = ["Point", "direct_sum", "direct_shift"] diff --git a/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_mixed_bind_c_f90/derived_types_mixed_bind_c_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_mixed_bind_c_f90/derived_types_mixed_bind_c_f90.pyi index 684d922a3..34bd41cb2 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_mixed_bind_c_f90/derived_types_mixed_bind_c_f90.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/routing/contracts/derived_types_mixed_bind_c_f90/derived_types_mixed_bind_c_f90.pyi @@ -1,7 +1,7 @@ from prik.contracts import Arg, Float64, Value, native_abi, native_call @native_abi("c") -class point: +class Point: def __init__( self, *, @@ -14,13 +14,13 @@ class point: @native_abi("c") def direct_sum( - value: point + value: Point ) -> Float64: ... @native_abi("c") @native_call([Value(Arg(0))]) def adapted_sum_by_value( - value: point + value: Point ) -> Float64: ... -__all__ = ["point", "direct_sum", "adapted_sum_by_value"] +__all__ = ["Point", "direct_sum", "adapted_sum_by_value"] diff --git a/tests/fortran/derived_types/end_to_end/test_abstract_hierarchy.py b/tests/fortran/derived_types/end_to_end/test_abstract_hierarchy.py index 4c13c1527..09ec762c8 100644 --- a/tests/fortran/derived_types/end_to_end/test_abstract_hierarchy.py +++ b/tests/fortran/derived_types/end_to_end/test_abstract_hierarchy.py @@ -27,24 +27,24 @@ def module(tmp_path_factory): def test_abstract_type_cannot_be_instantiated(module): """`type, abstract ::` has no instances, so its Python class has no constructor.""" with pytest.raises(TypeError, match="abstract native type and cannot be instantiated"): - module.shape_base() + module.Shape_Base() - assert "__init__" not in module.shape_base.__dict__ + assert "__init__" not in module.Shape_Base.__dict__ def test_extensions_are_python_subclasses_of_the_abstract_base(module): """Fortran `extends` becomes real Python inheritance, not copied members.""" - assert issubclass(module.circle, module.shape_base) - assert issubclass(module.square, module.shape_base) - assert module.circle.__mro__[:2] == (module.circle, module.shape_base) + assert issubclass(module.Circle, module.Shape_Base) + assert issubclass(module.Square, module.Shape_Base) + assert module.Circle.__mro__[:2] == (module.Circle, module.Shape_Base) - assert isinstance(module.circle(radius=np.float64(1.0)), module.shape_base) + assert isinstance(module.Circle(radius=np.float64(1.0)), module.Shape_Base) def test_deferred_bindings_dispatch_to_each_concrete_override(module): """A deferred binding names a contract; the dynamic type selects the body.""" - circle = module.circle(radius=np.float64(2.0)) - square = module.square(side=np.float64(3.0)) + circle = module.Circle(radius=np.float64(2.0)) + square = module.Square(side=np.float64(3.0)) assert circle.area() == pytest.approx(12.566370614, rel=1e-9) assert square.area() == pytest.approx(9.0) @@ -53,13 +53,13 @@ def test_deferred_bindings_dispatch_to_each_concrete_override(module): # The base declares the same bindings, and they resolve through the caller's # concrete type rather than through anything the abstract type implements. - assert module.shape_base.area(circle) == pytest.approx(circle.area()) - assert module.shape_base.area(square) == pytest.approx(square.area()) + assert module.Shape_Base.area(circle) == pytest.approx(circle.area()) + assert module.Shape_Base.area(square) == pytest.approx(square.area()) def test_inherited_bindings_and_components_reach_every_extension(module): """An implemented binding on the abstract base serves its extensions.""" - circle = module.circle(radius=np.float64(1.0)) + circle = module.Circle(radius=np.float64(1.0)) assert circle.side_count() == np.int32(0) circle.bump_sides() @@ -69,13 +69,13 @@ def test_inherited_bindings_and_components_reach_every_extension(module): def test_private_components_stay_off_the_generated_classes(module): """The hierarchy publishes only what its `private` statements allow.""" - assert {name for name in dir(module.shape_base) if not name.startswith("_")} == { + assert {name for name in dir(module.Shape_Base) if not name.startswith("_")} == { "area", "label", "side_count", "bump_sides", } - assert {name for name in dir(module.circle) if not name.startswith("_")} == { + assert {name for name in dir(module.Circle) if not name.startswith("_")} == { "area", "label", "side_count", @@ -86,7 +86,7 @@ def test_private_components_stay_off_the_generated_classes(module): def test_interoperable_type_keeps_its_layout_beside_the_hierarchy(module): """A `bind(c)` type in the same module still wraps through its own accessors.""" - box = module.extent(width=np.float64(3.0), height=np.float64(4.0)) + box = module.Extent(width=np.float64(3.0), height=np.float64(4.0)) assert box.width == np.float64(3.0) assert module.describe(box) == pytest.approx(12.0) diff --git a/tests/fortran/derived_types/end_to_end/test_borrowed_components.py b/tests/fortran/derived_types/end_to_end/test_borrowed_components.py index 44a364511..8f5992679 100644 --- a/tests/fortran/derived_types/end_to_end/test_borrowed_components.py +++ b/tests/fortran/derived_types/end_to_end/test_borrowed_components.py @@ -38,7 +38,7 @@ def test_borrowed_child_wrapper_never_finalizes_native_component( module = compiled_borrowed_component_module module.reset_final_count() - owner = module.parent() + owner = module.Parent() borrowed = owner.value del borrowed diff --git a/tests/fortran/derived_types/end_to_end/test_default_constructors_and_finalizers.py b/tests/fortran/derived_types/end_to_end/test_default_constructors_and_finalizers.py index 18d48cbf7..70c40f495 100644 --- a/tests/fortran/derived_types/end_to_end/test_default_constructors_and_finalizers.py +++ b/tests/fortran/derived_types/end_to_end/test_default_constructors_and_finalizers.py @@ -34,15 +34,15 @@ def test_fortran_default_constructor_keywords_and_finalization( module.reset_final_count() - defaulted = module.initialized() + defaulted = module.Initialized() assert defaulted.id == np.int32(7) assert defaulted.scale == np.float64(2.5) - partial = module.initialized(id=np.int32(11)) + partial = module.Initialized(id=np.int32(11)) assert partial.id == np.int32(11) assert partial.scale == np.float64(2.5) - keyword = module.initialized(id=np.int32(4), scale=np.float64(6.5)) + keyword = module.Initialized(id=np.int32(4), scale=np.float64(6.5)) assert keyword.id == np.int32(4) assert keyword.scale == np.float64(6.5) @@ -58,12 +58,12 @@ def test_fortran_default_constructor_keywords_and_finalization( assert module.get_final_count() == np.int32(3) with pytest.raises(TypeError): - module.initialized(np.int32(1)) + module.Initialized(np.int32(1)) gc.collect() assert module.get_final_count() == np.int32(4) with pytest.raises(TypeError): - module.initialized(missing=np.int32(1)) + module.Initialized(missing=np.int32(1)) gc.collect() assert module.get_final_count() == np.int32(5) diff --git a/tests/fortran/derived_types/end_to_end/test_derived_boundaries.py b/tests/fortran/derived_types/end_to_end/test_derived_boundaries.py index 987f52298..cce197d1e 100644 --- a/tests/fortran/derived_types/end_to_end/test_derived_boundaries.py +++ b/tests/fortran/derived_types/end_to_end/test_derived_boundaries.py @@ -32,7 +32,7 @@ def test_scalar_derived_types_cross_procedure_boundaries( pyi_parity_build_mode, ) - point = module.point() + point = module.Point() point.x = np.float64(1.0) point.y = np.float64(2.0) assert not hasattr(point, "hidden") @@ -44,21 +44,21 @@ def test_scalar_derived_types_cross_procedure_boundaries( assert point.x == np.float64(5.0) assert point.y == np.float64(7.0) - out_point = module.point() + out_point = module.Point() assert module.make_point_out(out_point, np.float64(8.0), np.float64(9.0)) is None assert out_point.x == np.float64(8.0) assert out_point.y == np.float64(9.0) result_point = module.make_point(np.float64(10.0), np.float64(11.0)) - assert isinstance(result_point, module.point) + assert isinstance(result_point, module.Point) assert result_point.x == np.float64(10.0) assert result_point.y == np.float64(11.0) - holder = module.holder() + holder = module.Holder() holder.scale = np.float64(2.5) assert module.set_holder_origin(holder, result_point) is None origin = holder.origin - assert isinstance(origin, module.point) + assert isinstance(origin, module.Point) assert origin.x == np.float64(10.0) origin.x = np.float64(12.0) assert module.holder_origin_x(holder) == np.float64(12.0) diff --git a/tests/fortran/derived_types/end_to_end/test_derived_direct_entrypoint_routing.py b/tests/fortran/derived_types/end_to_end/test_derived_direct_entrypoint_routing.py index 12814e415..dc6e37b43 100644 --- a/tests/fortran/derived_types/end_to_end/test_derived_direct_entrypoint_routing.py +++ b/tests/fortran/derived_types/end_to_end/test_derived_direct_entrypoint_routing.py @@ -32,7 +32,7 @@ def test_derived_all_direct_route_keeps_generated_type_support_separate( pyi_parity_build_mode, ) - value = module.point(x=np.float64(1.5), y=np.float64(2.5)) + value = module.Point(x=np.float64(1.5), y=np.float64(2.5)) references_before = sys.getrefcount(value) assert module.direct_sum(value) == np.float64(4.0) shifted = module.direct_shift(value, np.float64(2.0)) @@ -74,7 +74,7 @@ def test_derived_mixed_route_adapts_only_by_value_aggregate( pyi_parity_build_mode, ) - value = module.point(x=np.float64(2.0), y=np.float64(3.0)) + value = module.Point(x=np.float64(2.0), y=np.float64(3.0)) assert module.direct_sum(value) == np.float64(5.0) assert module.adapted_sum_by_value(value) == np.float64(5.0) @@ -100,7 +100,7 @@ def test_derived_mixed_route_matches_edited_source_free_contract(tmp_path: Path) tmp_path, module_name=stem, source_text=source, contract_text=contract ) - value = module.point(x=np.float64(2.0), y=np.float64(3.0)) + value = module.Point(x=np.float64(2.0), y=np.float64(3.0)) assert module.direct_sum(value) == np.float64(5.0) assert module.adapted_sum_by_value(value) == np.float64(5.0) bridge = (result.output_dir / f"bind_c_{stem}_wrapper.f90").read_text(encoding="utf-8").casefold() diff --git a/tests/fortran/derived_types/end_to_end/test_derived_runtime_mechanisms.py b/tests/fortran/derived_types/end_to_end/test_derived_runtime_mechanisms.py index c29886382..096ada377 100644 --- a/tests/fortran/derived_types/end_to_end/test_derived_runtime_mechanisms.py +++ b/tests/fortran/derived_types/end_to_end/test_derived_runtime_mechanisms.py @@ -445,7 +445,7 @@ def test_value_copy_and_optional_derived_inputs_match_source_oracle(tmp_path: Pa assert source_module.update_point(source_point) is None assert source_point.x == np.float64(11.0) assert source_point.y == np.float64(22.0) - source_filled = source_module.point() + source_filled = source_module.Point() assert source_module.fill_point(source_filled) is None assert source_filled.x == np.float64(31.0) assert source_filled.y == np.float64(32.0) diff --git a/tests/fortran/derived_types/end_to_end/test_generic_constructor.py b/tests/fortran/derived_types/end_to_end/test_generic_constructor.py index 6d9d4fd9f..2e2f90f08 100644 --- a/tests/fortran/derived_types/end_to_end/test_generic_constructor.py +++ b/tests/fortran/derived_types/end_to_end/test_generic_constructor.py @@ -26,16 +26,16 @@ def module(tmp_path_factory): def test_type_without_a_constructor_interface_keeps_keyword_fields(module): """No user constructor: the keyword-field `__init__` is unchanged.""" - value = module.plain(tag=np.int32(5)) + value = module.Plain(tag=np.int32(5)) assert value.tag == np.int32(5) def test_constructor_interface_overloads_init_from_its_specifics(module): """`interface `: each specific becomes an accepted signature.""" - empty = module.box() - from_count = module.box(np.int32(7)) - from_value = module.box(np.float64(2.5)) + empty = module.Box() + from_count = module.Box(np.int32(7)) + from_value = module.Box(np.float64(2.5)) assert (empty.count, empty.value) == (np.int32(0), np.float64(0.0)) assert (from_count.count, from_count.value) == (np.int32(7), np.float64(7.0)) @@ -45,13 +45,13 @@ def test_constructor_interface_overloads_init_from_its_specifics(module): def test_constructor_overload_rejects_an_unmatched_signature(module): """A call matching no specific is refused rather than guessed at.""" with pytest.raises(TypeError, match="no matching overload"): - module.box("not a supported signature") + module.Box("not a supported signature") def test_constructed_instances_are_independent_wrapper_objects(module): """Each accepted signature produces its own wrapper-owned instance.""" - first = module.box(np.int32(1)) - second = module.box(np.int32(2)) + first = module.Box(np.int32(1)) + second = module.Box(np.int32(2)) assert first is not second first.count = np.int32(9) diff --git a/tests/fortran/derived_types/end_to_end/test_inheritance_and_polymorphism.py b/tests/fortran/derived_types/end_to_end/test_inheritance_and_polymorphism.py index 273d486ac..281f4821f 100644 --- a/tests/fortran/derived_types/end_to_end/test_inheritance_and_polymorphism.py +++ b/tests/fortran/derived_types/end_to_end/test_inheritance_and_polymorphism.py @@ -31,16 +31,16 @@ def test_fortran_extension_types_generate_python_inheritance( pyi_parity_build_mode, ) - assert issubclass(module.circle, module.base_shape) - assert issubclass(module.box, module.base_shape) + assert issubclass(module.Circle, module.Base_Shape) + assert issubclass(module.Box, module.Base_Shape) - base = module.base_shape() + base = module.Base_Shape() base.size = np.float64(3.0) assert base.area() == np.float64(3.0) assert module.describe_shape(base) == np.float64(3.0) - circle = module.circle() - assert isinstance(circle, module.base_shape) + circle = module.Circle() + assert isinstance(circle, module.Base_Shape) circle.set_size(np.float64(5.0)) circle.radius = np.float64(2.0) assert circle.size == np.float64(5.0) @@ -48,11 +48,11 @@ def test_fortran_extension_types_generate_python_inheritance( np.testing.assert_allclose(circle.area(), expected_circle_area) np.testing.assert_allclose(module.describe_shape(circle), expected_circle_area) - module.base_shape.set_size(circle, np.float64(7.0)) + module.Base_Shape.set_size(circle, np.float64(7.0)) assert circle.size == np.float64(7.0) - box = module.box() - assert isinstance(box, module.base_shape) + box = module.Box() + assert isinstance(box, module.Base_Shape) box.set_size(np.float64(2.0)) box.width = np.float64(3.0) assert box.area() == np.float64(32.0) diff --git a/tests/fortran/derived_types/end_to_end/test_module_derived_aliases.py b/tests/fortran/derived_types/end_to_end/test_module_derived_aliases.py index 3b7c282a1..c077c48d1 100644 --- a/tests/fortran/derived_types/end_to_end/test_module_derived_aliases.py +++ b/tests/fortran/derived_types/end_to_end/test_module_derived_aliases.py @@ -40,7 +40,7 @@ def test_aliased_derived_module_object_borrows_native_state( ) current = module.current - assert isinstance(current, module.box) + assert isinstance(current, module.Box) values = current.values assert isinstance(values, AllocatableArray) assert values.owner is current @@ -55,7 +55,7 @@ def test_aliased_derived_module_object_borrows_native_state( assert module.current_sum() == np.float64(15.0) assert module.current.values_sum() == np.float64(15.0) - owned = module.box() + owned = module.Box() owned.allocate_values(np.int32(2)) owned.values.to_numpy()[0] = np.float64(20.0) assert owned.values_sum() == np.float64(22.0) diff --git a/tests/fortran/derived_types/end_to_end/test_opaque_layout.py b/tests/fortran/derived_types/end_to_end/test_opaque_layout.py index 0578d6f5c..abe0b62b1 100644 --- a/tests/fortran/derived_types/end_to_end/test_opaque_layout.py +++ b/tests/fortran/derived_types/end_to_end/test_opaque_layout.py @@ -42,7 +42,7 @@ def test_bind_c_derived_types_use_accessors_and_fortran_value_copy( assert "type(prik_type_tagged_point), pointer :: value" in bridge_source assert "result = native_score_by_value(value)" in bridge_source - value = module.tagged_point() + value = module.Tagged_Point() module.populate( value, np.float64(2.5), diff --git a/tests/fortran/derived_types/end_to_end/test_type_accessibility.py b/tests/fortran/derived_types/end_to_end/test_type_accessibility.py index ae7a8260b..958210d28 100644 --- a/tests/fortran/derived_types/end_to_end/test_type_accessibility.py +++ b/tests/fortran/derived_types/end_to_end/test_type_accessibility.py @@ -28,11 +28,11 @@ def test_accessibility_statements_shape_the_generated_class(tmp_path: Path): """ module = _build_source_and_import(SOURCE, tmp_path, GENERATED) - assert hasattr(module, "gated") - members = {name for name in dir(module.gated) if not name.startswith("_")} + assert hasattr(module, "Gated") + members = {name for name in dir(module.Gated) if not name.startswith("_")} assert members == {"shown", "step", "peek"} - instance = module.gated(shown=np.int32(5)) + instance = module.Gated(shown=np.int32(5)) assert instance.shown == np.int32(5) assert instance.peek() == np.int32(7) instance.step() diff --git a/tests/fortran/derived_types/end_to_end/test_type_bound_methods.py b/tests/fortran/derived_types/end_to_end/test_type_bound_methods.py index d3afd24c2..01129d999 100644 --- a/tests/fortran/derived_types/end_to_end/test_type_bound_methods.py +++ b/tests/fortran/derived_types/end_to_end/test_type_bound_methods.py @@ -31,7 +31,7 @@ def test_modern_fortran_derived_type_exposes_class_and_type_bound_methods( pyi_parity_build_mode, ) - assert "make(n, fill_value) -> vector_store" in module.vector_store.make.__doc__ - assert "n : int64" in module.vector_store.make.__doc__ - assert "wrapped native instance" not in module.vector_store.make.__doc__ + assert "make(n, fill_value) -> Vector_Store" in module.Vector_Store.make.__doc__ + assert "n : int64" in module.Vector_Store.make.__doc__ + assert "wrapped native instance" not in module.Vector_Store.make.__doc__ _assert_modern_class_examples(module) diff --git a/tests/fortran/enumerations/end_to_end/fixtures/contracts/fenums_f90/fenums_f90.pyi b/tests/fortran/enumerations/end_to_end/fixtures/contracts/fenums_f90/fenums_f90.pyi index fe0744f65..3111cce95 100644 --- a/tests/fortran/enumerations/end_to_end/fixtures/contracts/fenums_f90/fenums_f90.pyi +++ b/tests/fortran/enumerations/end_to_end/fixtures/contracts/fenums_f90/fenums_f90.pyi @@ -1,6 +1,6 @@ from prik.contracts import Addr, Arg, Final, Int32, native_call -class paint: +class Paint: def __init__( self, *, @@ -22,4 +22,4 @@ def round_trip_color( color: Int32 ) -> Int32: ... -__all__ = ["paint", "red", "blue", "green", "yellow", "round_trip_color"] +__all__ = ["Paint", "red", "blue", "green", "yellow", "round_trip_color"] diff --git a/tests/fortran/enumerations/end_to_end/test_enum_runtime.py b/tests/fortran/enumerations/end_to_end/test_enum_runtime.py index 52e04ddee..ad59ca6df 100644 --- a/tests/fortran/enumerations/end_to_end/test_enum_runtime.py +++ b/tests/fortran/enumerations/end_to_end/test_enum_runtime.py @@ -39,7 +39,7 @@ def test_fortran_enums_preserve_integer_runtime_surface( assert not hasattr(module, "Enum") assert not hasattr(module, "IntEnum") - sample = module.paint() + sample = module.Paint() assert sample.color == np.int32(-1) sample.color = np.int32(module.yellow) assert sample.color == np.int32(11) diff --git a/tests/fortran/generic_interfaces/codegen/test_overload_dispatch_plan.py b/tests/fortran/generic_interfaces/codegen/test_overload_dispatch_plan.py index f3188d803..0dbcc9440 100644 --- a/tests/fortran/generic_interfaces/codegen/test_overload_dispatch_plan.py +++ b/tests/fortran/generic_interfaces/codegen/test_overload_dispatch_plan.py @@ -60,7 +60,7 @@ def test_policy_completes_builtin_scalar_family_only_for_reflected_dispatch(): surface for namespace in plan.namespaces for surface in namespace.classes - if surface.type_identity[1] == "vector" + if surface.type_identity[1] == "Vector" ) overloads = {overload.python_name: overload for overload in vector.overloads} diff --git a/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foperators_f90/foperators_f90.pyi b/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foperators_f90/foperators_f90.pyi index ad8915f2d..2af65a6ed 100644 --- a/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foperators_f90/foperators_f90.pyi +++ b/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foperators_f90/foperators_f90.pyi @@ -1,6 +1,6 @@ from prik.contracts import Addr, Annotated, Arg, Bool32, Float64, Int32, Pass, Polymorphic, Returns, bind, native_call, overload, private -class vector: +class Vector: def __init__( self, *, @@ -12,91 +12,91 @@ class vector: @overload("add_vectors") def __add__( self, - right: vector - ) -> vector: ... + right: Vector + ) -> Vector: ... @overload("add_vector_integer") def __add__( self, right: Int32 - ) -> vector: ... + ) -> Vector: ... @overload("add_vector_real") def __add__( self, right: Float64 - ) -> vector: ... + ) -> Vector: ... @overload("add_real_vector") def __radd__( self, left: Float64 - ) -> vector: ... + ) -> Vector: ... @overload("add_vector_array") def __add__( self, right: Float64[::] - ) -> vector: ... + ) -> Vector: ... @overload("add_vector_offset") def __add__( self, - right: offset - ) -> vector: ... + right: Offset + ) -> Vector: ... @overload("positive_vector") - def __pos__(self) -> vector: ... + def __pos__(self) -> Vector: ... @overload("subtract_vector_real") def __sub__( self, right: Float64 - ) -> vector: ... + ) -> Vector: ... @overload("subtract_real_vector") def __rsub__( self, left: Float64 - ) -> vector: ... + ) -> Vector: ... @overload("negative_vector") - def __neg__(self) -> vector: ... + def __neg__(self) -> Vector: ... @overload("multiply_vector_real") def __mul__( self, right: Float64 - ) -> vector: ... + ) -> Vector: ... @overload("divide_vector_real") def __truediv__( self, right: Float64 - ) -> vector: ... + ) -> Vector: ... @overload("power_vector_integer") def __pow__( self, right: Int32 - ) -> vector: ... + ) -> Vector: ... @overload("equal_vectors") def __eq__( self, - right: vector + right: Vector ) -> Bool32: ... @overload("equivalent_vector_offset", generic="operator(.eqv.)") def __eq__( self, - right: offset + right: Offset ) -> Bool32: ... @overload("not_equal_vectors") def __ne__( self, - right: vector + right: Vector ) -> Bool32: ... @overload("not_equivalent_vector_integer", generic="operator(.neqv.)") @@ -108,7 +108,7 @@ class vector: @overload("less_vectors") def __lt__( self, - right: vector + right: Vector ) -> Bool32: ... @overload("less_vector_real") @@ -126,31 +126,31 @@ class vector: @overload("greater_vectors") def __gt__( self, - right: vector + right: Vector ) -> Bool32: ... @overload("less_equal_vectors") def __le__( self, - right: vector + right: Vector ) -> Bool32: ... @overload("greater_equal_vectors") def __ge__( self, - right: vector + right: Vector ) -> Bool32: ... @overload("and_vectors") def __and__( self, - right: vector + right: Vector ) -> Bool32: ... @overload("or_vectors") def __or__( self, - right: vector + right: Vector ) -> Bool32: ... @overload("not_vector") @@ -159,28 +159,28 @@ class vector: @overload("dot_vectors") def operator_dot( self, - right: vector + right: Vector ) -> Float64: ... @overload("shift_real_vector") def r_operator_shift( self, left: Float64 - ) -> vector: ... + ) -> Vector: ... @overload("assign_vector_integer") def assign( self, right: Int32 - ) -> vector: ... + ) -> Vector: ... @overload("assign_vector_real") def assign( self, right: Float64 - ) -> vector: ... + ) -> Vector: ... -class offset: +class Offset: def __init__( self, *, @@ -192,16 +192,16 @@ class offset: @overload("add_vector_offset") def __radd__( self, - left: vector - ) -> vector: ... + left: Vector + ) -> Vector: ... @overload("equivalent_vector_offset", generic="operator(.eqv.)") def __eq__( self, - left: vector + left: Vector ) -> Bool32: ... -class counter: +class Counter: def __init__( self, *, @@ -216,13 +216,13 @@ class counter: def add_integer( self, right: Int32 - ) -> counter: ... + ) -> Counter: ... @overload("counter_add_integer") def __add__( self, right: Int32 - ) -> counter: ... + ) -> Counter: ... @private @native_call([Addr(Arg(0))]) @@ -238,110 +238,110 @@ def convert_real( @private def add_vectors( - left: vector, - right: vector -) -> vector: ... + left: Vector, + right: Vector +) -> Vector: ... @private @native_call([Arg(0), Addr(Arg(1))]) def add_vector_integer( - left: vector, + left: Vector, right: Int32 -) -> vector: ... +) -> Vector: ... @private @native_call([Arg(0), Addr(Arg(1))]) def add_vector_real( - left: vector, + left: Vector, right: Float64 -) -> vector: ... +) -> Vector: ... @private @native_call([Addr(Arg(0)), Arg(1)]) def add_real_vector( left: Float64, - right: vector -) -> vector: ... + right: Vector +) -> Vector: ... @private def add_vector_array( - left: vector, + left: Vector, right: Float64[::] -) -> vector: ... +) -> Vector: ... @private def add_vector_offset( - left: vector, - right: offset -) -> vector: ... + left: Vector, + right: Offset +) -> Vector: ... @private def positive_vector( - value: vector -) -> vector: ... + value: Vector +) -> Vector: ... @private @native_call([Arg(0), Addr(Arg(1))]) def subtract_vector_real( - left: vector, + left: Vector, right: Float64 -) -> vector: ... +) -> Vector: ... @private @native_call([Addr(Arg(0)), Arg(1)]) def subtract_real_vector( left: Float64, - right: vector -) -> vector: ... + right: Vector +) -> Vector: ... @private def negative_vector( - value: vector -) -> vector: ... + value: Vector +) -> Vector: ... @private @native_call([Arg(0), Addr(Arg(1))]) def multiply_vector_real( - left: vector, + left: Vector, right: Float64 -) -> vector: ... +) -> Vector: ... @private @native_call([Arg(0), Addr(Arg(1))]) def divide_vector_real( - left: vector, + left: Vector, right: Float64 -) -> vector: ... +) -> Vector: ... @private @native_call([Arg(0), Addr(Arg(1))]) def power_vector_integer( - left: vector, + left: Vector, right: Int32 -) -> vector: ... +) -> Vector: ... @private def equal_vectors( - left: vector, - right: vector + left: Vector, + right: Vector ) -> Bool32: ... @private def not_equal_vectors( - left: vector, - right: vector + left: Vector, + right: Vector ) -> Bool32: ... @private def less_vectors( - left: vector, - right: vector + left: Vector, + right: Vector ) -> Bool32: ... @private @native_call([Arg(0), Addr(Arg(1))]) def less_vector_real( - left: vector, + left: Vector, right: Float64 ) -> Bool32: ... @@ -349,90 +349,90 @@ def less_vector_real( @native_call([Addr(Arg(0)), Arg(1)]) def less_real_vector( left: Float64, - right: vector + right: Vector ) -> Bool32: ... @private def less_equal_vectors( - left: vector, - right: vector + left: Vector, + right: Vector ) -> Bool32: ... @private def greater_vectors( - left: vector, - right: vector + left: Vector, + right: Vector ) -> Bool32: ... @private def greater_equal_vectors( - left: vector, - right: vector + left: Vector, + right: Vector ) -> Bool32: ... @private def and_vectors( - left: vector, - right: vector + left: Vector, + right: Vector ) -> Bool32: ... @private def or_vectors( - left: vector, - right: vector + left: Vector, + right: Vector ) -> Bool32: ... @private def not_vector( - value: vector + value: Vector ) -> Bool32: ... @private def equivalent_vector_offset( - left: vector, - right: offset + left: Vector, + right: Offset ) -> Bool32: ... @private @native_call([Arg(0), Addr(Arg(1))]) def not_equivalent_vector_integer( - left: vector, + left: Vector, right: Int32 ) -> Bool32: ... @private def dot_vectors( - left: vector, - right: vector + left: Vector, + right: Vector ) -> Float64: ... @private @native_call([Addr(Arg(0)), Arg(1)]) def shift_real_vector( left: Float64, - right: vector -) -> vector: ... + right: Vector +) -> Vector: ... @private @native_call([Arg(0), Addr(Arg(1))]) def assign_vector_integer( - left: vector, + left: Vector, right: Int32 -) -> Returns["left", vector]: ... +) -> Returns["left", Vector]: ... @private @native_call([Arg(0), Addr(Arg(1))]) def assign_vector_real( - left: vector, + left: Vector, right: Float64 -) -> Returns["left", vector]: ... +) -> Returns["left", Vector]: ... @private @native_call([Arg(0), Addr(Arg(1))]) def counter_add_integer( - self: Annotated[counter, Polymorphic], + self: Annotated[Counter, Polymorphic], right: Int32 -) -> counter: ... +) -> Counter: ... @bind("convert") @overload("convert_integer") @@ -446,4 +446,4 @@ def convert( value: Float64 ) -> Float64: ... -__all__ = ["vector", "offset", "counter", "convert"] +__all__ = ["Vector", "Offset", "Counter", "convert"] diff --git a/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_f90/foverloads_f90.pyi b/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_f90/foverloads_f90.pyi index 06b568d68..173490b6b 100644 --- a/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_f90/foverloads_f90.pyi +++ b/tests/fortran/generic_interfaces/end_to_end/fixtures/contracts/foverloads_f90/foverloads_f90.pyi @@ -1,6 +1,6 @@ from prik.contracts import Addr, Annotated, Arg, Complex128, Float64, Int32, Pass, Polymorphic, bind, native_call, overload, private -class accumulator: +class Accumulator: def __init__( self, *, @@ -39,7 +39,7 @@ class accumulator: value: Float64 ) -> None: ... -class sample: +class Sample: def __init__( self, *, @@ -79,25 +79,25 @@ def summarize_vector( @private def inspect_accumulator( - value: accumulator + value: Accumulator ) -> Float64: ... @private def inspect_sample( - value: sample + value: Sample ) -> Float64: ... @private @native_call([Arg(0), Addr(Arg(1))]) def accumulator_add_integer( - self: Annotated[accumulator, Polymorphic], + self: Annotated[Accumulator, Polymorphic], value: Int32 ) -> None: ... @private @native_call([Arg(0), Addr(Arg(1))]) def accumulator_add_real( - self: Annotated[accumulator, Polymorphic], + self: Annotated[Accumulator, Polymorphic], value: Float64 ) -> None: ... @@ -134,13 +134,13 @@ def summarize( @bind("inspect") @overload("inspect_accumulator") def inspect( - value: accumulator + value: Accumulator ) -> Float64: ... @bind("inspect") @overload("inspect_sample") def inspect( - value: sample + value: Sample ) -> Float64: ... -__all__ = ["accumulator", "sample", "convert", "summarize", "inspect"] +__all__ = ["Accumulator", "Sample", "convert", "summarize", "inspect"] diff --git a/tests/fortran/generic_interfaces/end_to_end/test_defined_operators.py b/tests/fortran/generic_interfaces/end_to_end/test_defined_operators.py index 6ceca3ec7..5d658ffc2 100644 --- a/tests/fortran/generic_interfaces/end_to_end/test_defined_operators.py +++ b/tests/fortran/generic_interfaces/end_to_end/test_defined_operators.py @@ -33,21 +33,21 @@ def test_fortran_defined_operators_and_assignment_dispatch_in_generated_c_extens ) def vector(value): - result = module.vector() + result = module.Vector() result.value = np.float64(value) return result def offset(value): - result = module.offset() + result = module.Offset() result.value = np.float64(value) return result left = vector(5.0) right = vector(2.0) - assert "__add__(*args, **kwargs)" in module.vector.__doc__ - assert "__add__(right: vector) -> vector" in module.vector.__add__.__doc__ - assert "add_vectors" not in module.vector.__add__.__doc__ + assert "__add__(*args, **kwargs)" in module.Vector.__doc__ + assert "__add__(right: Vector) -> Vector" in module.Vector.__add__.__doc__ + assert "add_vectors" not in module.Vector.__add__.__doc__ assert module.convert(np.int32(2)) == np.int32(12) assert module.convert(np.float64(2.0)) == np.float64(2.5) @@ -96,7 +96,7 @@ def offset(value): assert assigned.assign(assigned) is assigned assert assigned.value == np.float64(3.5) - counter = module.counter() + counter = module.Counter() counter.value = np.int32(4) assert (counter + np.int32(3)).value == np.int32(7) 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 79d17fe79..407722285 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 @@ -90,13 +90,13 @@ def test_fortran_generic_interfaces_dispatch_in_generated_c_extension( assert module.summarize(np.float64(2.5)) == np.float64(2.5) assert module.summarize(np.array([1.0, 2.0, 3.0], dtype=np.float64)) == np.float64(6.0) - value = module.accumulator() + value = module.Accumulator() value.add(np.int32(2)) value.add(value=np.float64(0.5)) assert value.total == np.float64(2.5) assert module.inspect(value) == np.float64(2.5) - sample = module.sample() + sample = module.Sample() sample.value = np.float64(7.25) assert module.inspect(sample) == np.float64(7.25) 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 9f2e286ec..9dcbf8d69 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 as box def box_value( item: box diff --git a/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/shared_types.pyi b/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/shared_types.pyi index 411244aa4..a2f48835e 100644 --- a/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/shared_types.pyi +++ b/tests/fortran/infrastructure/building/end_to_end/fixtures/contracts/multiple_files/combined_modules/shared_types.pyi @@ -1,6 +1,6 @@ from prik.contracts import Addr, Arg, Int32, native_call -class box: +class Box: def __init__( self, *, @@ -12,6 +12,6 @@ class box: @native_call([Addr(Arg(0))]) def make_box( value: Int32 -) -> box: ... +) -> Box: ... -__all__ = ["box", "make_box"] +__all__ = ["Box", "make_box"] 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 fcf8766b7..56e36772f 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 @@ -299,7 +299,7 @@ def test_multi_source_pyi_out_writes_one_flat_combined_package(tmp_path: Path): "from . import first_math\nfrom . import shared_types\nfrom . import second_math\nfrom . import box_ops\n\n" '__all__ = ["first_math", "shared_types", "second_math", "box_ops"]\n' ) - assert "from .shared_types import box" in (package / "box_ops.pyi").read_text(encoding="utf-8") + assert "from .shared_types import Box as 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") @@ -336,9 +336,9 @@ def test_multi_source_generated_contract_build_matches_source_runtime_and_link_o # `box_ops` imports the type to express its own signature and publishes no # name of its own, so neither route adds one. The type stays where it is # declared, and both builds agree on that. - assert not hasattr(generated_module.box_ops, "box") - assert not hasattr(source_module.box_ops, "box") - assert generated_module.shared_types.box is not None + assert not hasattr(generated_module.box_ops, "Box") + assert not hasattr(source_module.box_ops, "Box") + assert generated_module.shared_types.Box is not None def test_generated_module_leaf_loads_sibling_type_contract(tmp_path: Path): @@ -357,7 +357,7 @@ def test_generated_module_leaf_loads_sibling_type_contract(tmp_path: Path): str(entry.parent / "box_ops.pyi"), str(entry.parent / "shared_types.pyi"), ] - box = module.box() + box = module.Box() box.value = np.int32(7) assert module.box_value(box) == np.int32(7) diff --git a/tests/fortran/infrastructure/building/end_to_end/test_source_build_modes.py b/tests/fortran/infrastructure/building/end_to_end/test_source_build_modes.py index 502b60480..7f6d8ba71 100644 --- a/tests/fortran/infrastructure/building/end_to_end/test_source_build_modes.py +++ b/tests/fortran/infrastructure/building/end_to_end/test_source_build_modes.py @@ -323,7 +323,7 @@ def test_documented_readme_points_example_builds_and_imports(tmp_path: Path): points = geometry.points assert points.__name__ == "geometry.points" assert points.norm_squared.__module__ == "geometry.points" - item = points.point(x=np.float64(3.0), y=np.float64(4.0)) + item = points.Point(x=np.float64(3.0), y=np.float64(4.0)) points.move(item, np.float64(1.0), np.float64(-2.0)) assert item.x == np.float64(4.0) assert item.y == np.float64(2.0) diff --git a/tests/fortran/infrastructure/naming/test_policy.py b/tests/fortran/infrastructure/naming/test_policy.py index cdc8c0e27..db655f640 100644 --- a/tests/fortran/infrastructure/naming/test_policy.py +++ b/tests/fortran/infrastructure/naming/test_policy.py @@ -36,6 +36,30 @@ def test_two_spellings_collide_only_where_the_source_folds_them(): assert preserving.reserve_public_name((), "foo", category="function") == "foo" +def test_a_wrapped_type_is_named_as_a_python_class(): + """A derived type reaches Python as a class, so PRIK spells it like one.""" + assert normalize_public_name("point_t", category="class").name == "Point_T" + assert normalize_public_name("my_particle_type", category="class").name == "My_Particle_Type" + assert normalize_public_name("accumulator", category="class").name == "Accumulator" + # Fortran writes one type under many spellings, so the style does not + # depend on which one the source happened to use. + assert normalize_public_name("POINT_T", category="class").name == "Point_T" + # Every other declaration keeps the lower-case form. + assert normalize_public_name("point_t").name == "point_t" + + +def test_a_source_that_spells_its_own_types_keeps_that_spelling(): + """C names each declaration exactly, so PRIK has no spelling to choose.""" + assert normalize_public_name("point", preserve_case=True, category="class").name == "point" + assert normalize_public_name("Point", preserve_case=True, category="class").name == "Point" + + +def test_a_chosen_class_style_is_not_a_name_python_forced(): + """Strict naming rejects what Python cannot spell, not how PRIK cases it.""" + assert normalize_public_name("point_t", category="class").needs_fix is False + assert normalize_public_name("point t", category="class").needs_fix is True + + def test_public_python_names_escape_keywords_and_collisions(): policy = NamingPolicy() diff --git a/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/fixtures/visibility/contracts/fnaming_f90/fnaming_f90.pyi b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/fixtures/visibility/contracts/fnaming_f90/fnaming_f90.pyi index 755a401e7..4116a049a 100644 --- a/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/fixtures/visibility/contracts/fnaming_f90/fnaming_f90.pyi +++ b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/fixtures/visibility/contracts/fnaming_f90/fnaming_f90.pyi @@ -1,6 +1,6 @@ from prik.contracts import Addr, Annotated, Arg, Int32, SourceName, bind, native_call -class visible_t: +class Visible_T: def __init__( self, *, @@ -11,7 +11,7 @@ class visible_t: lambda_: Annotated[Int32, SourceName("lambda")] = 3 lambda__2: Annotated[Int32, SourceName("lambda_")] = 4 - @bind("visible_t.from") + @bind("Visible_T.from") def from_(self) -> Int32: ... value: Int32 @@ -30,4 +30,4 @@ def lambda__2( def get_value() -> Int32: ... -__all__ = ["visible_t", "value", "lambda_", "lambda__2", "get_value"] +__all__ = ["Visible_T", "value", "lambda_", "lambda__2", "get_value"] diff --git a/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/test_contract_names_match_build.py b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/test_contract_names_match_build.py new file mode 100644 index 000000000..77465ac9e --- /dev/null +++ b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/test_contract_names_match_build.py @@ -0,0 +1,137 @@ +"""The contract a source build writes names the declarations that build published.""" + +import ast +from pathlib import Path + +import numpy as np +import pytest + +from prik.pipeline.build import BUILD_CONTRACT_DIRECTORY_NAME, build_fortran_extension +from tests.fortran._support.wrapper_build import _build_inline_pyi_contract_module, _sole_native_module + +pytestmark = pytest.mark.fortran_end_to_end + +# A module variable and a module procedure whose source names both want the +# Python name `lambda_`. The collision crosses declaration categories, so any +# stage naming them in a different order settles the pair the other way round. +CROSS_CATEGORY_COLLISION = """ +module collide_mod + implicit none + integer :: lambda = 7 +contains + integer function lambda_() + lambda_ = 1 + end function lambda_ +end module collide_mod +""" + + +def _declared_names(contract: Path) -> dict[str, str]: + """Map each declaration in one contract to the kind of statement declaring it.""" + module = ast.parse(contract.read_text(encoding="utf-8"), filename=str(contract)) + declared: dict[str, str] = {} + for statement in module.body: + if isinstance(statement, ast.FunctionDef): + declared[statement.name] = "function" + elif isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name): + declared[statement.target.id] = "variable" + elif isinstance(statement, ast.ClassDef): + declared[statement.name] = "class" + return declared + + +def _stated_exports(contract: Path) -> list[str]: + """Return the ``__all__`` a generated contract states about itself.""" + module = ast.parse(contract.read_text(encoding="utf-8"), filename=str(contract)) + for statement in module.body: + targets = getattr(statement, "targets", []) + if any(isinstance(target, ast.Name) and target.id == "__all__" for target in targets): + return [ast.literal_eval(element) for element in statement.value.elts] + raise AssertionError(f"{contract} states no __all__") + + +def test_a_cross_category_collision_settles_the_same_way_for_both(tmp_path: Path): + """One stage owns the name, so the contract cannot bind the pair the other way.""" + source = tmp_path / "collide_mod.f90" + source.write_text(CROSS_CATEGORY_COLLISION, encoding="utf-8") + + result = build_fortran_extension( + source, + output_dir=tmp_path / "build", + output_name="collide_api", + ) + namespace = _sole_native_module(result.import_module()) + contract = result.output_dir / BUILD_CONTRACT_DIRECTORY_NAME / "collide_mod.pyi" + + declared = _declared_names(contract) + assert set(declared) == set(_stated_exports(contract)) + + # The build decides which declaration each name reaches; naming the same + # pair in a different order would swap these two and leave every individual + # assertion above still passing. + for name, kind in declared.items(): + published = getattr(namespace, name) + if kind == "function": + assert callable(published), f"contract declares {name} a function; the build published {published!r}" + assert published() == np.int32(1) + else: + assert not callable(published), f"contract declares {name} a variable; the build published {published!r}" + assert published == np.int32(7) + + +def test_a_derived_type_is_named_once_for_the_build_and_its_contract(tmp_path: Path): + """A class name is a public name too, so the same owner settles it.""" + source = tmp_path / "typed_mod.f90" + source.write_text( + """ +module typed_mod + implicit none + type :: Point_T + integer :: x = 3 + end type Point_T +end module typed_mod +""", + encoding="utf-8", + ) + + result = build_fortran_extension( + source, + output_dir=tmp_path / "build", + output_name="typed_api", + ) + namespace = _sole_native_module(result.import_module()) + contract = result.output_dir / BUILD_CONTRACT_DIRECTORY_NAME / "typed_mod.pyi" + + declared = _declared_names(contract) + assert set(declared) == set(_stated_exports(contract)) + for name in declared: + assert hasattr(namespace, name), f"contract declares {name}; the build published {dir(namespace)}" + + +def test_an_edited_contract_keeps_arbitrary_class_capitalization(tmp_path: Path): + """The generated class style is a default; an edited contract owns its spelling.""" + module, _result = _build_inline_pyi_contract_module( + tmp_path, + module_name="mixed_case_contract_mod", + source_text=""" +module mixed_case_contract_mod + implicit none + type :: point + integer :: x = 3 + end type point +end module mixed_case_contract_mod +""", + contract_text=""" +from prik.contracts import Int32 + +class pOiNt: + def __init__(self, *, x: Int32 = 3) -> None: ... + + x: Int32 + +__all__ = ["pOiNt"] +""", + ) + + value = module.pOiNt() + assert value.x == np.int32(3) diff --git a/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/test_visibility_naming.py b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/test_visibility_naming.py index 53b1723b3..46730f207 100644 --- a/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/test_visibility_naming.py +++ b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/test_visibility_naming.py @@ -43,10 +43,10 @@ def test_visibility_and_default_python_name_fixing_policy( assert not hasattr(module, "get_value_2") assert not hasattr(module, "set_value") - assert not hasattr(module, "hidden_t") + assert not hasattr(module, "Hidden_T") assert not hasattr(module, "hidden_proc") - item = module.visible_t(lambda_=np.int32(5), lambda__2=np.int32(6)) + item = module.Visible_T(lambda_=np.int32(5), lambda__2=np.int32(6)) assert item.lambda_ == 5 assert item.lambda__2 == 6 assert item.from_() == 11 diff --git a/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/pipeline/test_declaring_namespace_publication.py b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/pipeline/test_declaring_namespace_publication.py new file mode 100644 index 000000000..527e4d352 --- /dev/null +++ b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/pipeline/test_declaring_namespace_publication.py @@ -0,0 +1,165 @@ +"""Which namespace may publish a name, by the kind of declaration it names. + +A procedure or a derived type reaches Python as one object, so another +namespace can bind it and PRIK re-exports it through an alias. A module +variable holds state that stays live where it is declared and a generic is a +dispatch surface rather than one object, so neither reaches Python as something +a second namespace can bind at all. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from prik.pipeline.build import build_pyi_extension + +GENERIC_SOURCE = """\ +module home + implicit none + integer :: counter = 5 + interface area + module procedure area_i, area_r + end interface area +contains + integer function area_i(v) + integer, intent(in) :: v + area_i = v + end function area_i + real(8) function area_r(v) + real(8), intent(in) :: v + area_r = v + end function area_r + integer function scale_value(v) + integer, intent(in) :: v + scale_value = v * 2 + end function scale_value +end module home +""" + +HOME_CONTRACT = """\ +from prik.contracts import Addr, Arg, Float64, Int32, native_call, overload + +counter: Int32 + +@native_call([Addr(Arg(0))]) +def area_i( + v: Int32 +) -> Int32: ... + +@native_call([Addr(Arg(0))]) +def area_r( + v: Float64 +) -> Float64: ... + +@native_call([Addr(Arg(0))]) +def scale_value( + v: Int32 +) -> Int32: ... + +@overload("area_i") +def area( + v: Int32 +) -> Int32: ... + +@overload("area_r") +def area( + v: Float64 +) -> Float64: ... + +__all__ = {home_exports} +""" + + +def _package(tmp_path: Path, *, home_exports: list[str], facade: str) -> Path: + """Write a two-namespace contract package and return its entry contract.""" + (tmp_path / "home.f90").write_text(GENERIC_SOURCE, encoding="utf-8") + package = tmp_path / "contracts" + package.mkdir() + (package / "home.pyi").write_text(HOME_CONTRACT.format(home_exports=home_exports), encoding="utf-8") + (package / "facade.pyi").write_text(facade, encoding="utf-8") + (package / "__init__.pyi").write_text( + 'from . import home\nfrom . import facade\n\n__all__ = ["home", "facade"]\n', + encoding="utf-8", + ) + return package / "__init__.pyi" + + +def _plan(entry: Path, tmp_path: Path, name: str): + """Plan a build from one contract package without compiling it.""" + return build_pyi_extension( + entry, + native_fortran_sources=[str(tmp_path / "home.f90")], + output_dir=tmp_path / "build", + output_name=name, + generate_sources=True, + ) + + +ALL_NAMES = ["counter", "area_i", "area_r", "scale_value", "area"] + + +@pytest.mark.parametrize("name", ["counter", "area"]) +def test_the_declaring_namespace_may_publish_either_kind(name: str, tmp_path: Path): + """Publishing one where it is declared is what a source build already does.""" + entry = _package(tmp_path, home_exports=ALL_NAMES, facade="__all__ = []\n") + + result = _plan(entry, tmp_path, f"declaring_only_{name}") + + assert result.output_dir.is_dir() + + +@pytest.mark.parametrize( + ("name", "kind"), + [("counter", "module variable"), ("area", "generic")], +) +def test_a_facade_beside_the_declaring_namespace_is_refused(name: str, kind: str, tmp_path: Path): + """Two namespaces would need two bindings of something that has only one.""" + entry = _package( + tmp_path, + home_exports=ALL_NAMES, + facade=f'from .home import {name}\n\n__all__ = ["{name}"]\n', + ) + + with pytest.raises(ValueError) as error: + _plan(entry, tmp_path, f"both_{name}") + + message = str(error.value) + assert f"{kind} {name!r} is declared in home and published in facade" in message + assert "publishable only by the namespace declaring it" in message + + +@pytest.mark.parametrize( + ("name", "kind"), + [("counter", "module variable"), ("area", "generic")], +) +def test_moving_either_kind_to_a_facade_alone_is_refused(name: str, kind: str, tmp_path: Path): + """Relocating publishes it in one place and still not where it lives. + + Counting namespaces would accept this, because withholding the name at + home leaves exactly one publisher. + """ + entry = _package( + tmp_path, + home_exports=[item for item in ALL_NAMES if item != name], + facade=f'from .home import {name}\n\n__all__ = ["{name}"]\n', + ) + + with pytest.raises(ValueError) as error: + _plan(entry, tmp_path, f"facade_only_{name}") + + assert f"{kind} {name!r} is declared in home and published in facade" in str(error.value) + + +def test_a_procedure_still_reaches_python_through_a_facade(tmp_path: Path): + """A procedure is one object, so a second namespace binds the same one.""" + entry = _package( + tmp_path, + home_exports=ALL_NAMES, + facade='from .home import scale_value\n\n__all__ = ["scale_value"]\n', + ) + + result = _plan(entry, tmp_path, "procedure_facade") + + assert result.output_dir.is_dir() 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 a95a95d4e..3d48c6c5b 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 @@ -22,6 +22,7 @@ SemanticField, SemanticFunction, SemanticImport, + SemanticImportItem, SemanticModule, SemanticOrigin, SemanticStorageContract, @@ -260,6 +261,34 @@ def test_emit_module_stubs_honors_available_opaque_dependency_modules(): assert set(stubs) == {"api"} +def test_emit_module_stubs_uses_available_module_public_names_in_imports(): + origin = SemanticOrigin(source_language="fortran") + available_type = SemanticModule( + name="types", + classes=[SemanticClass(name="point_t", origin=origin)], + origin=origin, + ) + consumer = SemanticModule( + name="consumer", + imports=[ + SemanticImport( + module="types", + items=[SemanticImportItem(source="point_t")], + ) + ], + origin=origin, + ) + + stubs = emit_module_stubs( + consumer, + available_modules=[available_type, consumer], + normalize_public_names=True, + ) + + assert set(stubs) == {"consumer"} + assert "from .types import Point_T as point_t" in stubs["consumer"] + + def test_emit_omits_resolved_source_kind_imports(): source = """ module user_mod @@ -895,8 +924,8 @@ def test_generated_contract_states_the_names_its_source_publishes(): # The publishing module names the import; the consuming one does not. assert stubs["surface_facade"].rstrip().endswith('__all__ = ["scale_value"]') assert stubs["surface_consumer"].rstrip().endswith('__all__ = ["crate_value"]') - assert "from .surface_home import box as crate" in stubs["surface_consumer"] - assert '__all__ = ["box", "scale_value"]' in stubs["surface_home"] + assert "from .surface_home import Box as crate" in stubs["surface_consumer"] + assert '__all__ = ["Box", "scale_value"]' in stubs["surface_home"] def test_a_published_intrinsic_name_states_no_contract_import(): diff --git a/tests/fortran/modules/end_to_end/fixtures/contracts/fmodule_vars_f90/fmodule_vars_f90.pyi b/tests/fortran/modules/end_to_end/fixtures/contracts/fmodule_vars_f90/fmodule_vars_f90.pyi index 244ca68fd..ecb394757 100644 --- a/tests/fortran/modules/end_to_end/fixtures/contracts/fmodule_vars_f90/fmodule_vars_f90.pyi +++ b/tests/fortran/modules/end_to_end/fixtures/contracts/fmodule_vars_f90/fmodule_vars_f90.pyi @@ -1,6 +1,6 @@ from prik.contracts import Final, Float64, Int32 -class rgb_color: +class Rgb_Color: def __init__( self, *, @@ -15,7 +15,7 @@ class rgb_color: nmax: Final[Int32] = 12 -black: Final[rgb_color] +black: Final[Rgb_Color] counter: Int32 @@ -32,7 +32,7 @@ def next_local() -> Int32: ... def black_sum() -> Int32: ... __all__ = [ - "rgb_color", + "Rgb_Color", "nmax", "black", "counter", 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 38a0e623d..38f4227f6 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 @@ -53,7 +53,7 @@ def test_scalar_module_variables_use_attributes_and_parameters_have_no_native_se assert "Assignment writes through to native storage." not in module_docstring assert module.nmax == np.int32(12) - assert isinstance(module.black, module.rgb_color) + assert isinstance(module.black, module.Rgb_Color) assert module.black.r == np.int32(0) assert module.black.g == np.int32(0) assert module.black.b == np.int32(0) diff --git a/tests/fortran/optional_arguments/end_to_end/fixtures/contracts/foptional_f90/foptional_f90.pyi b/tests/fortran/optional_arguments/end_to_end/fixtures/contracts/foptional_f90/foptional_f90.pyi index e592ff85f..d226df026 100644 --- a/tests/fortran/optional_arguments/end_to_end/fixtures/contracts/foptional_f90/foptional_f90.pyi +++ b/tests/fortran/optional_arguments/end_to_end/fixtures/contracts/foptional_f90/foptional_f90.pyi @@ -1,6 +1,6 @@ from prik.contracts import Addr, Arg, Float64, Int32, Returns, String, native_call -class sample: +class Sample: def __init__( self, *, @@ -15,7 +15,7 @@ def summarize( scale: Int32 = ..., values: Float64[::] = ..., label: String = ..., - item: sample = ... + item: Sample = ... ) -> Int32: ... @native_call([Arg(0), Addr(Arg(1))]) @@ -36,4 +36,4 @@ def optional_status( status: Int32[()] = ... ) -> tuple[Int32, Returns["status", Int32[()]] | None]: ... -__all__ = ["sample", "summarize", "mutate_optional", "fill_optional", "optional_status"] +__all__ = ["Sample", "summarize", "mutate_optional", "fill_optional", "optional_status"] diff --git a/tests/fortran/optional_arguments/end_to_end/test_optional_runtime.py b/tests/fortran/optional_arguments/end_to_end/test_optional_runtime.py index 1f3d76f76..d1b1b5134 100644 --- a/tests/fortran/optional_arguments/end_to_end/test_optional_runtime.py +++ b/tests/fortran/optional_arguments/end_to_end/test_optional_runtime.py @@ -108,7 +108,7 @@ def test_optional_arguments_drive_fortran_present_behavior( assert "May be omitted or passed as None." in module.summarize.__doc__ values = np.array([1.0, 2.0, 3.0], dtype=np.float64) - item = module.sample() + item = module.Sample() item.value = np.int32(7) assert module.summarize(np.int32(5)) == np.int32(5) diff --git a/tests/fortran/pointers/end_to_end/test_pointer_handles.py b/tests/fortran/pointers/end_to_end/test_pointer_handles.py index b33c95872..9996ed027 100644 --- a/tests/fortran/pointers/end_to_end/test_pointer_handles.py +++ b/tests/fortran/pointers/end_to_end/test_pointer_handles.py @@ -321,7 +321,7 @@ def test_module_and_derived_pointer_handles_track_native_association( assert module_handle.associated is False assert module_handle.shape is None - owner = module.pointer_box() + owner = module.Pointer_Box() field_handle = owner.values assert isinstance(field_handle, PointerArray) assert field_handle.owner is owner diff --git a/tests/fortran/subroutines/end_to_end/test_assumed_scalar_intent.py b/tests/fortran/subroutines/end_to_end/test_assumed_scalar_intent.py index d03c44744..8515b18c0 100644 --- a/tests/fortran/subroutines/end_to_end/test_assumed_scalar_intent.py +++ b/tests/fortran/subroutines/end_to_end/test_assumed_scalar_intent.py @@ -48,7 +48,7 @@ def test_assumed_scalar_intent_returns_only_the_function_result(tmp_path: Path): def test_assumed_scalar_intent_keeps_array_and_derived_writeback(tmp_path: Path): module = _module(tmp_path, assume_intent_in_scalars=True) - item = module.sample(x=np.float64(1.0)) + item = module.Sample(x=np.float64(1.0)) values = np.array([1.0, 2.0, 3.0], dtype=np.float64) assert module.touch(np.int32(5), item, values) is None diff --git a/tests/fortran/subroutines/end_to_end/test_documented_subroutine_journey.py b/tests/fortran/subroutines/end_to_end/test_documented_subroutine_journey.py index c59090887..191876a31 100644 --- a/tests/fortran/subroutines/end_to_end/test_documented_subroutine_journey.py +++ b/tests/fortran/subroutines/end_to_end/test_documented_subroutine_journey.py @@ -47,7 +47,7 @@ def test_subroutine_outputs_and_caller_storage_follow_documented_projection_rule assert module.no_intent_scalar(no_intent) == np.float64(6.0) assert no_intent == np.float64(5.0) - point = module.point() + point = module.Point() assert module.fill_point(point) is None assert point.x == np.float64(9.5) From 773f0953d9988f98637a24db49d71002e698b6cc Mon Sep 17 00:00:00 2001 From: said Date: Wed, 16 Sep 2026 19:28:48 +0100 Subject: [PATCH 34/96] codex: preserve module variable reexports --- CHANGELOG.md | 19 +-- docs/developer/packages/planning.md | 9 ++ docs/user/guide/wrapping-modules.md | 4 + .../pyi-contracts/exports-and-modules.md | 33 ++++- docs/user/reference/pyi-format.md | 15 +- prik/codegen/c/binding.py | 39 ++++- prik/codegen/docstrings.py | 32 ++++- prik/pipeline/build.py | 42 ++++-- prik/pipeline/wrapper.py | 30 +++- prik/planning/__init__.py | 2 + prik/planning/entrypoints.py | 12 ++ prik/planning/models.py | 14 ++ prik/planning/planner.py | 57 ++++++-- prik/policy/exports.py | 38 ++++- prik/printers/pyi.py | 7 +- .../infrastructure/codegen/test_planner.py | 24 ++++ .../test_declaring_namespace_publication.py | 63 ++++---- .../test_pyi_printer_imports_and_packages.py | 14 +- .../test_module_variables_and_state.py | 136 ++++++++++++++++++ 19 files changed, 487 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b309440..e3653f2e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,12 @@ release tags add a leading `v` to the package version. ## Unreleased -- A module variable and a generic are published only by the namespace declaring - them, which is now checked against that namespace rather than by counting the - namespaces publishing them. Counting caught a contract listing one beside its - declaring contract but not one moving it to a facade, because withholding the - name at home left exactly one publisher -- a relocation the source route has - no way to produce. A procedure and a derived type each reach Python as one - object and are re-exported through aliases as before. +- A module-variable re-export now publishes another live route to the declaring + variable instead of being omitted or rejected. Every namespace reuses one + completed variable plan and its native accessors, so scalar assignment, array + mutation, allocation, pointer association, derived state, and read-only + parameters retain one native identity. Generic interfaces remain publishable + only by their declaring namespace. - A wrapper's Python names are decided once, by post-IR export policy, and every stage that writes a name reads that decision. The `.pyi` printer @@ -60,12 +59,6 @@ release tags add a leading `v` to the package version. asking for it, which a contract needing it to express a declaration -- or meaning to publish it itself -- still does. -- A contract publishes only what a build of the same Fortran source can. A - module variable and a generic reach Python in the namespace declaring them, - so a generated contract states neither as a re-export, and a contract asking - for one is refused rather than given a second projection the source route - has no way to produce. - - A contract's `__all__` names its sub-namespaces as well, so leaving one off keeps the package from exposing it. A generated entry contract states the modules it imports for that reason, and a contract stating no list still diff --git a/docs/developer/packages/planning.md b/docs/developer/packages/planning.md index 3126bdc83..ecb7e7162 100644 --- a/docs/developer/packages/planning.md +++ b/docs/developer/packages/planning.md @@ -73,6 +73,7 @@ ModulePlan │ │ └── BridgeCallSlotPlan (optional adapter facet) │ └── LifecycleActionPlan └── ModuleVariablePlan + └── ModuleVariablePublicationPlan (one or more namespace bindings) ``` Each callable, argument, and result always owns binding and entrypoint views; @@ -83,6 +84,14 @@ projection, presence and length fields, descriptors, and hidden outputs. Bridge records own adapter-local representation conversion and the invocation of the original Fortran procedure. +One `ModuleVariablePlan` owns each declaring native variable and its completed +getter, setter, ownership, descriptor, array, and derived-object mechanisms. +`ModuleVariablePublicationPlan` records only a namespace and Python names that +publish that plan. Re-exporting module state therefore adds publication records +without adding variable plans, accessors, support procedures, initialization, +allocation state, or pointer state. Parameters use the same structure while +retaining constant-value lowering. + `NativeEntrypointModulePlan.support_procedures` is the authoritative registry for externally linked generated helper callables that are not ordinary wrapped functions. Each operation stores one collision-safe key and symbol plus a diff --git a/docs/user/guide/wrapping-modules.md b/docs/user/guide/wrapping-modules.md index bd861b851..6b533ca5d 100644 --- a/docs/user/guide/wrapping-modules.md +++ b/docs/user/guide/wrapping-modules.md @@ -220,6 +220,10 @@ Public functions, variables, constants, and generated classes are exported at the extension root. If the original module imports were replaced, `library.module1` and `library.module2` are no longer exported. The native Fortran modules and their storage do not move; only the Python API changes. +Publishing a module variable in more than one namespace gives every name the +same live storage, so a write, allocation, pointer association, or derived +object mutation through one name is visible through all of them. Parameters +remain read-only constants in every namespace. Wildcard imports never use import order to resolve a collision. If both modules export the same name, the wrapper build fails and asks for an explicit diff --git a/docs/user/reference/pyi-contracts/exports-and-modules.md b/docs/user/reference/pyi-contracts/exports-and-modules.md index 5a98b78e6..c69df47a6 100644 --- a/docs/user/reference/pyi-contracts/exports-and-modules.md +++ b/docs/user/reference/pyi-contracts/exports-and-modules.md @@ -41,8 +41,9 @@ native module or select a native object file. Only declarations reachable from `__init__.pyi` are public. Missing files, import cycles, and two different exports using the same Python name are -errors. Explicit aliases share the same native target, but Python object -identity is not guaranteed for every read. +errors. Explicit aliases share the same native target. A module-variable alias +reads and writes the declaring variable's live native storage; it does not +create another variable. ## Remove or Hide a Declaration @@ -158,6 +159,34 @@ nmax: Final[Int32] = 12 See [Wrapping Modules](../../guide/wrapping-modules.md#shape-the-module-api-with-the-contract) for the resulting Python usage. +## Re-export Module State + +Import a variable into another leaf and include it in that leaf's `__all__`: + +```python +# state.pyi +from prik.contracts import Int32 + +counter: Int32 +__all__ = ["counter"] +``` + +```python +# facade.pyi +from .state import counter + +__all__ = ["counter"] +``` + +Both `package.state.counter` and `package.facade.counter` access the same +native variable. Assignment, array mutation, allocation, pointer association, +and derived-object changes made through either namespace are immediately +visible through the other. PRIK completes the variable's access and ownership +policy once; the second namespace changes publication only. + +A re-exported `Final[...]` parameter remains the same read-only constant. It +does not gain setter or storage machinery. + ## Next Use [Functions and Classes](functions-and-classes.md) to add methods, diff --git a/docs/user/reference/pyi-format.md b/docs/user/reference/pyi-format.md index 2a99a2246..7b87fed8f 100644 --- a/docs/user/reference/pyi-format.md +++ b/docs/user/reference/pyi-format.md @@ -209,17 +209,16 @@ name declares decides how publishing it appears: | Derived type | A runtime type. | | Package sub-namespace | A runtime namespace attribute. | | Prototype | A callback signature contracts name, with no runtime object. | -| Module variable | Live state, publishable only by the namespace declaring it. | +| Module variable | Live state or a constant; every publication reaches the declaring variable. | | Generic interface | A dispatch surface, publishable only by the namespace declaring it. | A procedure and a derived type each reach Python as one object, so another -namespace can bind that object and PRIK re-exports it under whatever name the -importing contract states. A module variable and a generic reach Python as -neither, so no other namespace can publish one. Every namespace naming one of -those two kinds in its `__all__` is checked against the namespace declaring it: -listing it beside the declaring contract is refused, and so is moving it to a -facade by withholding it at home, which publishes it in exactly one namespace -and still not the one it lives in. +namespace can bind that object under whatever name the importing contract +states. A module-variable re-export instead installs another route to the same +declaring variable: reads, writes, allocation, pointer association, and derived +object state remain shared. A `Final[...]` parameter is published with the same +constant semantics in every namespace. A generic is a dispatch surface rather +than one object and remains publishable only by its declaring namespace. PRIK writes the list into every generated contract, holding what the Fortran source publishes: the module's own public declarations, and any imported name it diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 645423a1d..c0ef6365c 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -113,6 +113,7 @@ LifecycleActionPlan, ModulePlan, ModuleVariablePlan, + ModuleVariablePublicationPlan, NamespacePlan, NativeArrayHandlePlan, NativeEntrypointABIValueKind, @@ -14479,14 +14480,14 @@ def _module_property_support( ), reject_replacement=(variable.binding.setter_action is SetterAction.REJECT_REPLACEMENT), ) - for variable in namespace.variables + for variable, publication in self._variable_publications(module, namespace) if variable.binding.getter_action not in { ModuleGetterAction.CONSTANT_VALUE, ModuleGetterAction.NATIVE_CONSTANT_VALUE, ModuleGetterAction.NATIVE_CONSTANT_ARRAY_VALUE, } - for python_name in variable.binding.python_names + for python_name in publication.python_names ) if not entries: return None @@ -15482,7 +15483,7 @@ def _namespace_configuration_nodes( *self._module_native_array_owner_nodes(namespace, object_name), *self._derived_module_owner_nodes(namespace, object_name), *self._module_initializer_nodes(namespace), - *self._module_constant_nodes(namespace, object_name), + *self._module_constant_nodes(module, namespace, object_name), ) def _namespace_python_initializer_nodes( @@ -15587,22 +15588,29 @@ def _module_initializer_nodes(self, namespace: NamespacePlan) -> tuple[CExpressi def _module_constant_nodes( self, + plan: ModulePlan, namespace: NamespacePlan, module_object: str, ) -> tuple[CDeclaration | CExpressionStatement, ...]: """Materialize scalar constants in the ordinary module dictionary.""" nodes = [] index = 0 - for variable in namespace.variables: + namespace_symbol = self._namespace_symbol(namespace) + for variable, publication in self._variable_publications(plan, namespace): if variable.binding.getter_action not in { ModuleGetterAction.CONSTANT_VALUE, ModuleGetterAction.NATIVE_CONSTANT_VALUE, ModuleGetterAction.NATIVE_CONSTANT_ARRAY_VALUE, }: continue - for python_name in variable.binding.python_names: - value_name = f"constant_{variable.symbol_name}_value_{index}" - object_name = f"constant_{variable.symbol_name}_object_{index}" + local_stem = ( + variable.symbol_name + if any(item.owner_path == variable.owner_path for item in namespace.variables) + else f"{namespace_symbol}_{variable.symbol_name}" + ) + for python_name in publication.python_names: + value_name = f"constant_{local_stem}_value_{index}" + object_name = f"constant_{local_stem}_object_{index}" nodes.extend( ( *self._module_constant_declarations(variable, value_name, object_name), @@ -15813,6 +15821,23 @@ def _variables(self, plan: ModulePlan) -> tuple[ModuleVariablePlan, ...]: """Return variables from the supplied completed binding records; this helper preserves the selected binding behavior.""" return tuple(variable for namespace in plan.namespaces for variable in namespace.variables) + def _variable_publications( + self, + plan: ModulePlan, + namespace: NamespacePlan, + ) -> tuple[tuple[ModuleVariablePlan, ModuleVariablePublicationPlan], ...]: + """Resolve namespace publications to their one native variable plan.""" + variables = {variable.owner_path: variable for variable in self._variables(plan)} + resolved = [] + for publication in namespace.variable_publications: + variable = variables.get(publication.variable_owner_path) + if variable is None: + raise ValueError( + f"Module-variable publication references missing plan {publication.variable_owner_path!r}" + ) + resolved.append((variable, publication)) + return tuple(resolved) + def _namespace(self, plan: ModulePlan, python_path: tuple[str, ...]) -> NamespacePlan: """Return the binding-local namespace derived from the supplied completed binding records; this helper preserves completed policy.""" for namespace in plan.namespaces: diff --git a/prik/codegen/docstrings.py b/prik/codegen/docstrings.py index 9f59f7838..4aa5c11fc 100644 --- a/prik/codegen/docstrings.py +++ b/prik/codegen/docstrings.py @@ -34,6 +34,7 @@ FunctionPlan, ModulePlan, ModuleVariablePlan, + ModuleVariablePublicationPlan, NamespacePlan, OverloadPlan, ResultPlan, @@ -98,6 +99,14 @@ def render(self, plan: ModulePlan) -> ModulePlan: for surface in namespace.classes if surface.python_names } + self._module_variables_by_owner = { + variable.owner_path: variable for namespace in plan.namespaces for variable in namespace.variables + } + # A publication can sort before the namespace that owns its canonical + # variable plan. Render every canonical variable first so namespace + # summaries only read completed documentation from that owner. + for variable in self._module_variables_by_owner.values(): + self._render_module_variable(variable) for namespace in plan.namespaces: self._render_namespace(plan.owner_path, namespace) return plan @@ -120,11 +129,15 @@ def _render_namespace(self, module_name: str, namespace: NamespacePlan) -> None: self._render_class_surface(surface, () if derived_type is None else derived_type.fields) if namespace.docstring is None: + variable_publications = tuple( + (self._module_variables_by_owner[publication.variable_owner_path], publication) + for publication in namespace.variable_publications + ) namespace.docstring = self.namespace( module_name, namespace.python_path, namespace.functions, - namespace.variables, + variable_publications, namespace.classes, namespace.overloads, ) @@ -194,7 +207,7 @@ def namespace( module_name: str, path: tuple[str, ...], functions: tuple[FunctionPlan, ...], - variables: tuple[ModuleVariablePlan, ...], + variables: tuple[tuple[ModuleVariablePlan, ModuleVariablePublicationPlan], ...], classes, overloads: tuple[OverloadPlan, ...], ) -> str: @@ -215,7 +228,11 @@ def namespace( self._append_section( lines, "Module Attributes", - tuple(line for variable in variables for line in self._module_variable_summary_lines(variable)), + tuple( + line + for variable, publication in variables + for line in self._module_variable_summary_lines(variable, publication.python_names) + ), ) self._append_section(lines, "Functions", callable_lines) self._append_section(lines, "Classes", tuple(name for surface in classes for name in surface.python_names)) @@ -1140,7 +1157,11 @@ def _result_name(result: ResultPlan, arguments: tuple[ArgumentTransferPlan, ...] return result.projected_call_slot.python_name return "result" if result.result_position == 0 else f"result_{result.result_position}" - def _module_variable_summary_lines(self, variable: ModuleVariablePlan) -> tuple[str, ...]: + def _module_variable_summary_lines( + self, + variable: ModuleVariablePlan, + python_names: tuple[str, ...] | None = None, + ) -> tuple[str, ...]: """Expand one module-variable docstring for every exported Python alias. The first line supplies the rendered type while the remaining details @@ -1154,7 +1175,8 @@ def _module_variable_summary_lines(self, variable: ModuleVariablePlan) -> tuple[ _name, separator, type_name = first.partition(" : ") if not separator: return (first,) - return tuple(line for name in variable.binding.python_names for line in (f"{name} : {type_name}", *details)) + names = variable.binding.python_names if python_names is None else python_names + return tuple(line for name in names for line in (f"{name} : {type_name}", *details)) def _keyword_field_signature( self, diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index 384c73d79..4f30f09de 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -2259,21 +2259,13 @@ def _reject_unsupported_republication( module: SemanticModule, home: tuple[str, ...] | None, ) -> None: - """Refuse a published name whose kind reaches Python only where it is declared. + """Refuse a generic published outside the namespace declaring it. - A module variable holds state that stays live where it is declared, and a - generic is a dispatch surface rather than one object, so neither reaches - Python as an object another namespace can bind. A procedure or a derived - type does, which is why those two are re-exported through aliases instead. - - Every namespace publishing one of these kinds is therefore checked against - the namespace declaring it, not merely counted: moving one to a facade - publishes it in exactly one place and still says what no build can do. + A module variable has a dedicated publication plan that routes every + namespace to one native variable plan. A generic remains a dispatch + surface rather than one bindable object, so it cannot be republished. """ - for declaration, kind in ( - *((item, "module variable") for item in module.variables), - *((item, "generic") for item in module.overload_sets), - ): + for declaration, kind in ((item, "generic") for item in module.overload_sets): exports = _declaration_exports(declaration) relocated = [export for export in exports if home is None or tuple(export["namespace"]) != home] if not relocated: @@ -2382,6 +2374,30 @@ def _apply_source_python_exports(modules: list[SemanticModule]) -> None: ), ) + variables_by_identity = { + (module.name.casefold(), str(variable.origin.native_name or variable.name).casefold()): variable + for module in modules + for variable in module.variables + } + for module in modules: + for reexport in module.reexports: + if reexport.entity_kind != "variable": + continue + variable = variables_by_identity.get( + (str(reexport.origin_module).casefold(), str(reexport.source_name).casefold()) + ) + if variable is None: + raise ValueError( + f"Cannot resolve re-exported module variable {reexport.origin_module}.{reexport.source_name}" + ) + export = { + "namespace": tuple(part.casefold() for part in str(reexport.module).split(".") if part), + "name": str(reexport.local_name), + } + exports = _declaration_exports(variable) + if export not in exports: + exports.append(export) + # Native build inputs and link planning diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index e7a81ad39..63f98bb8f 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -325,6 +325,7 @@ def _plan_diagnostics(self, plan: ModulePlan) -> tuple[WrapperPlanDiagnostic, .. ) diagnostics.extend(self._generated_support_procedure_entrypoint_diagnostics(plan)) diagnostics.extend(self._namespace_tree_diagnostics(plan)) + diagnostics.extend(self._module_variable_publication_diagnostics(plan)) # Validate every typed member against the shared records in its namespace. for namespace in plan.namespaces: @@ -1023,7 +1024,7 @@ def _valid_nested_derived_field(field) -> bool: def _python_export_name_diagnostics(self, plan: NamespacePlan) -> tuple[WrapperPlanDiagnostic, ...]: """Return duplicate local export-name diagnostics.""" names = [function.binding.python_name for function in plan.functions] - names.extend(name for variable in plan.variables for name in variable.binding.python_names) + names.extend(name for publication in plan.variable_publications for name in publication.python_names) names.extend(name for derived in plan.derived_types for name in derived.python_names) names.extend(overload.python_name for overload in plan.overloads) return tuple( @@ -1057,6 +1058,33 @@ def _export_owner_diagnostics(self, plan: NamespacePlan) -> tuple[WrapperPlanDia ) return tuple(diagnostics) + def _module_variable_publication_diagnostics( + self, + plan: ModulePlan, + ) -> tuple[WrapperPlanDiagnostic, ...]: + """Validate that every publication references one canonical variable plan.""" + owners = {variable.owner_path for namespace in plan.namespaces for variable in namespace.variables} + diagnostics = [] + for namespace in plan.namespaces: + for publication in namespace.variable_publications: + if publication.variable_owner_path not in owners: + diagnostics.append( + self._diagnostic( + namespace.owner_path, + "missing-module-variable-publication-owner", + publication.variable_owner_path, + ) + ) + if not publication.python_names: + diagnostics.append( + self._diagnostic( + namespace.owner_path, + "empty-module-variable-publication", + publication.variable_owner_path, + ) + ) + return tuple(diagnostics) + def _generated_symbol_diagnostics(self, plan: ModulePlan) -> tuple[WrapperPlanDiagnostic, ...]: """Reject missing or colliding C/Fortran symbol stems before lowering.""" owners_by_symbol: dict[str, list[str]] = {} diff --git a/prik/planning/__init__.py b/prik/planning/__init__.py index 445d25155..b2f5bf4dd 100644 --- a/prik/planning/__init__.py +++ b/prik/planning/__init__.py @@ -38,6 +38,7 @@ LifecycleActionPlan, ModulePlan, ModuleVariablePlan, + ModuleVariablePublicationPlan, NativeGeneratedCodeGroupKind, NativeGeneratedCodeGroupPlan, NativeEntrypointABIValueKind, @@ -93,6 +94,7 @@ "LifecycleActionPlan", "ModulePlan", "ModuleVariablePlan", + "ModuleVariablePublicationPlan", "NamespacePlan", "NativeArrayActualPlan", "NativeArrayDefaultHandlePlan", diff --git a/prik/planning/entrypoints.py b/prik/planning/entrypoints.py index 527fa4ebf..bff7211bf 100644 --- a/prik/planning/entrypoints.py +++ b/prik/planning/entrypoints.py @@ -492,6 +492,13 @@ def _allocatable_holder_types(self) -> tuple[DerivedTypePlan, ...]: if case.action is not DerivedCallAction.INCOMPATIBLE ) ) + identities.update( + variable.derived.handoff.type_identity + for variable in self.variables + if variable.derived is not None + and variable.derived.handoff.storage + in {DerivedObjectStorage.MODULE_ALLOCATABLE, DerivedObjectStorage.MODULE_ALLOCATABLE_TARGET} + ) return tuple(derived for derived in self.derived_types if derived.type_identity in identities) def _pointer_holder_types(self) -> tuple[DerivedTypePlan, ...]: @@ -513,6 +520,11 @@ def _pointer_holder_types(self) -> tuple[DerivedTypePlan, ...]: if case.action is not DerivedCallAction.INCOMPATIBLE ) ) + identities.update( + variable.derived.handoff.type_identity + for variable in self.variables + if variable.derived is not None and variable.derived.handoff.storage is DerivedObjectStorage.MODULE_POINTER + ) return tuple(derived for derived in self.derived_types if derived.type_identity in identities) def _allocatable_holder_field_types(self) -> tuple[DerivedTypePlan, ...]: diff --git a/prik/planning/models.py b/prik/planning/models.py index d3a2d1db2..0c4af07bc 100644 --- a/prik/planning/models.py +++ b/prik/planning/models.py @@ -804,6 +804,19 @@ class ModuleVariablePlan(StageRecord): array_address: ModuleArrayAddressMechanism | None = None +@dataclass +class ModuleVariablePublicationPlan(StageRecord): + """Publish one existing module-variable plan in a Python namespace. + + ``variable_owner_path`` identifies the sole plan that owns native access, + storage, initialization, and support procedures. This record adds only + Python names in one namespace; it never creates another variable plan. + """ + + variable_owner_path: str + python_names: tuple[str, ...] + + @dataclass class BindingFunctionPlan(StageRecord): """Store Python-visible call behavior for one generated binding function. @@ -1413,6 +1426,7 @@ class NamespacePlan(StageRecord): python_path: tuple[str, ...] functions: tuple[FunctionPlan, ...] = () variables: tuple[ModuleVariablePlan, ...] = () + variable_publications: tuple[ModuleVariablePublicationPlan, ...] = () derived_types: tuple[DerivedTypePlan, ...] = () classes: tuple[ClassSurfacePlan, ...] = () overloads: tuple[OverloadPlan, ...] = () diff --git a/prik/planning/planner.py b/prik/planning/planner.py index af49d0083..fb968f9c3 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -117,6 +117,7 @@ LifecycleActionPlan, ModulePlan, ModuleVariablePlan, + ModuleVariablePublicationPlan, NativeGeneratedCodeGroupKind, NativeGeneratedCodeGroupPlan, GeneratedSupportProcedureImplementationOwner, @@ -371,7 +372,7 @@ def _visit_SemanticModule(self, module: models.SemanticModule) -> ModulePlan: self._complete_derived_backend_symbols(semantic_classes) # Project every public surface before linking private callable entries. - functions, variables, derived_types, classes, overloads = self._namespace_member_plans( + functions, variables, variable_publications, derived_types, classes, overloads = self._namespace_member_plans( module, class_policies, ) @@ -380,6 +381,7 @@ def _visit_SemanticModule(self, module: models.SemanticModule) -> ModulePlan: ( *functions.values(), *variables.values(), + *variable_publications.values(), *derived_types.values(), *classes.values(), *overloads.values(), @@ -394,7 +396,14 @@ def _visit_SemanticModule(self, module: models.SemanticModule) -> ModulePlan: # Complete stable namespace paths, generated symbols, and required headers. namespaces = self._namespace_plans( - module.name, functions, variables, derived_types, classes, overloads, aliases + module.name, + functions, + variables, + variable_publications, + derived_types, + classes, + overloads, + aliases, ) support_projection = build_generated_support_procedure_projection(namespaces) support_procedures = support_projection.support_procedures @@ -482,7 +491,7 @@ def _namespace_member_plans( self, module: models.SemanticModule, class_policies: _ClassPolicyCatalog, - ) -> tuple[dict, dict, dict, dict, dict]: + ) -> tuple[dict, dict, dict, dict, dict, dict]: """Build namespace-owned plan maps from one shared class-policy catalog. Direct functions and variables are projected first. The local catalog @@ -492,11 +501,12 @@ def _namespace_member_plans( """ # Project ordinary module members independently from class-owned surfaces. functions = self._functions_by_namespace(module) - variables = self._variables_by_namespace(module) + variables, variable_publications = self._variables_by_namespace(module) return ( functions, variables, + variable_publications, self._derived_types_by_namespace(class_policies), self._classes_by_namespace(module.name, class_policies), self._module_overloads_by_namespace(module), @@ -520,6 +530,7 @@ def _namespace_plans( module_name: str, functions: dict, variables: dict, + variable_publications: dict, derived_types: dict, classes: dict, overloads: dict, @@ -528,7 +539,7 @@ def _namespace_plans( """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, *aliases) + (*functions, *variables, *variable_publications, *derived_types, *classes, *overloads, *aliases) ) return tuple( self._namespace_plan( @@ -536,6 +547,7 @@ def _namespace_plans( path, tuple(functions[path]), tuple(variables[path]), + tuple(variable_publications[path]), tuple(derived_types[path]), tuple(classes[path]), tuple(overloads[path]), @@ -612,6 +624,7 @@ def _namespace_plan( path: tuple[str, ...], functions: tuple[FunctionPlan, ...], variables: tuple[ModuleVariablePlan, ...], + variable_publications: tuple[ModuleVariablePublicationPlan, ...], derived_types: tuple[DerivedTypePlan, ...], classes: tuple[ClassSurfacePlan, ...], overloads: tuple[OverloadPlan, ...], @@ -623,6 +636,7 @@ def _namespace_plan( python_path=path, functions=functions, variables=variables, + variable_publications=variable_publications, derived_types=derived_types, classes=classes, overloads=overloads, @@ -1126,9 +1140,13 @@ def _module_function_policy(function: models.SemanticFunction) -> FunctionWrappe def _variables_by_namespace( self, module: models.SemanticModule, - ) -> dict[tuple[str, ...], list[ModuleVariablePlan]]: - """Group exported module-variable plans by completed Python namespace.""" + ) -> tuple[ + dict[tuple[str, ...], list[ModuleVariablePlan]], + dict[tuple[str, ...], list[ModuleVariablePublicationPlan]], + ]: + """Plan each native variable once and group its Python publications.""" variables = defaultdict(list) + publications = defaultdict(list) for variable in module.variables: if variable.visibility != "public": continue @@ -1136,11 +1154,30 @@ def _variables_by_namespace( exports_by_namespace = defaultdict(list) for export in policy.python_exports: exports_by_namespace[export.namespace].append(export.name) + native_namespace = tuple(part.casefold() for part in str(policy.native_module).split(".") if part) + declaring_namespace = ( + native_namespace + if native_namespace in exports_by_namespace + else () + if () in exports_by_namespace and str(policy.native_module).casefold() == module.name.casefold() + else native_namespace + ) + declaring_names = tuple(exports_by_namespace.get(declaring_namespace, ())) or (policy.name,) + plan = self._module_variable_plan( + policy, + declaring_namespace, + declaring_names, + module.name, + ) + variables[declaring_namespace].append(plan) for namespace, python_names in exports_by_namespace.items(): - variables[namespace].append( - self._module_variable_plan(policy, namespace, tuple(python_names), module.name) + publications[namespace].append( + ModuleVariablePublicationPlan( + variable_owner_path=plan.owner_path, + python_names=tuple(python_names), + ) ) - return variables + return variables, publications def _complete_generated_symbols( self, diff --git a/prik/policy/exports.py b/prik/policy/exports.py index 15ca96cbc..4a9c7b22e 100644 --- a/prik/policy/exports.py +++ b/prik/policy/exports.py @@ -64,8 +64,7 @@ def complete_python_export_policy( category="function" if contract_named else category, owner=f"{category} {owner.name}", ) - if export.get("name") is None: - export["name"] = resolved_name + export["name"] = resolved_name _complete_reexport_names(module, naming, contract_named=contract_named) @@ -85,7 +84,15 @@ def _complete_reexport_names( for reexport in module.reexports: if reexport.python_name: continue - category = "class" if reexport.entity_kind == "derived_type" else "function" + if reexport.entity_kind == "variable": + completed_name = _completed_variable_reexport_name(module, reexport) + if completed_name is not None: + reexport.python_name = completed_name + continue + category = { + "derived_type": "class", + "variable": "variable", + }.get(reexport.entity_kind, "function") reexport.python_name = naming.reserve_public_name( _reexport_namespace(module, reexport), reexport.local_name, @@ -94,6 +101,31 @@ def _complete_reexport_names( ) +def _completed_variable_reexport_name( + module: models.SemanticModule, + reexport: models.SemanticReexport, +) -> str | None: + """Read a variable re-export name from its declaring variable policy. + + A merged source build contains the declaring variable, whose export list is + the authority for every publication. Contract extraction may emit an + importing module separately, in which case the declaration is unavailable + and the re-export is named locally instead. + """ + wanted_module = str(reexport.origin_module).casefold() + wanted_name = str(reexport.source_name).casefold() + namespace = _reexport_namespace(module, reexport) + for variable in module.variables: + native_module = str(variable.origin.native_scope or "").casefold() + native_name = str(variable.origin.native_name or variable.name).casefold() + if native_module != wanted_module or native_name != wanted_name: + continue + for export in variable.metadata.get(models.PYTHON_EXPORTS_METADATA, ()): + if export_namespace(export) == namespace and export.get("name") is not None: + return str(export["name"]) + return None + + def _reexport_namespace(module: models.SemanticModule, reexport: models.SemanticReexport) -> tuple[str, ...]: """Return the Python namespace one re-export publishes into. diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index 1fe4291d3..7b6c5ad0f 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -199,7 +199,7 @@ def _public_owner_key(owner: object) -> object: # Publication of these kinds has no runtime form yet, so a generated contract # does not claim it. -_UNPUBLISHABLE_REEXPORT_KINDS = frozenset({"variable", "generic"}) +_UNPUBLISHABLE_REEXPORT_KINDS = frozenset({"generic"}) class PyiPrinter(ClassVisitor): @@ -748,9 +748,8 @@ def _module_exported_names( if self._is_source_kind_import(str(reexport.origin_module)): continue if reexport.entity_kind in _UNPUBLISHABLE_REEXPORT_KINDS: - # A live module variable and a generic dispatcher have no single - # object another namespace can bind, so a source build publishes - # neither and a contract generated from it states neither. + # A generic dispatcher has no single object another namespace + # can bind, so a source build does not publish it here. continue # A prototype keeps its declared spelling wherever it is written, so # the name published for it is the one its import binds. diff --git a/tests/fortran/infrastructure/codegen/test_planner.py b/tests/fortran/infrastructure/codegen/test_planner.py index 6f9f33ecd..461c3040a 100644 --- a/tests/fortran/infrastructure/codegen/test_planner.py +++ b/tests/fortran/infrastructure/codegen/test_planner.py @@ -72,6 +72,30 @@ def right_value(x: Int32) -> Int32: ... assert plan.namespaces[2].functions[0].symbol_name == "right_shared_value" +def test_planner_keeps_one_module_variable_plan_for_multiple_publications(): + """Namespace publications reference one plan that owns native access.""" + module = parse_pyi_text("counter: Int32\n", module_name="state") + module.variables[0].metadata[PYTHON_EXPORTS_METADATA] = [ + {"namespace": (), "name": "counter"}, + {"namespace": ("facade",), "name": "counter"}, + ] + complete_semantic_policies(module) + + plan = WrapperPlanner().build(module) + + variables = [variable for namespace in plan.namespaces for variable in namespace.variables] + publications = [ + (namespace.python_path, publication.variable_owner_path, publication.python_names) + for namespace in plan.namespaces + for publication in namespace.variable_publications + ] + assert len(variables) == 1 + assert publications == [ + ((), variables[0].owner_path, ("counter",)), + (("facade",), variables[0].owner_path, ("counter",)), + ] + + def test_two_python_names_one_folded_stem_get_separate_generated_symbols(): """A generated symbol is shared with Fortran, which folds the two together.""" module = parse_pyi_text( diff --git a/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/pipeline/test_declaring_namespace_publication.py b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/pipeline/test_declaring_namespace_publication.py index 527e4d352..0cfa80d8e 100644 --- a/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/pipeline/test_declaring_namespace_publication.py +++ b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/pipeline/test_declaring_namespace_publication.py @@ -2,9 +2,9 @@ A procedure or a derived type reaches Python as one object, so another namespace can bind it and PRIK re-exports it through an alias. A module -variable holds state that stays live where it is declared and a generic is a -dispatch surface rather than one object, so neither reaches Python as something -a second namespace can bind at all. +variable likewise permits multiple publications, but all of them refer to the +one variable plan and its live native state. A generic remains a dispatch +surface rather than one object, so only its declaring namespace can publish it. """ from __future__ import annotations @@ -110,46 +110,55 @@ def test_the_declaring_namespace_may_publish_either_kind(name: str, tmp_path: Pa assert result.output_dir.is_dir() -@pytest.mark.parametrize( - ("name", "kind"), - [("counter", "module variable"), ("area", "generic")], -) -def test_a_facade_beside_the_declaring_namespace_is_refused(name: str, kind: str, tmp_path: Path): - """Two namespaces would need two bindings of something that has only one.""" +def test_a_facade_may_publish_the_declaring_namespaces_variable(tmp_path: Path): + """A second publication reads the declaring variable's completed plan.""" entry = _package( tmp_path, home_exports=ALL_NAMES, - facade=f'from .home import {name}\n\n__all__ = ["{name}"]\n', + facade='from .home import counter\n\n__all__ = ["counter"]\n', ) - with pytest.raises(ValueError) as error: - _plan(entry, tmp_path, f"both_{name}") + result = _plan(entry, tmp_path, "both_counter") - message = str(error.value) - assert f"{kind} {name!r} is declared in home and published in facade" in message - assert "publishable only by the namespace declaring it" in message + generated = (result.output_dir / "both_counter_wrapper.c").read_text(encoding="utf-8") + assert generated.count("static PyObject * module_get_counter(void) {") == 1 + assert generated.count("static int module_set_counter(PyObject * value_obj) {") == 1 + + +def test_a_facade_may_be_the_only_publication_of_a_declared_variable(tmp_path: Path): + """Withholding the declaring name changes publication, not ownership.""" + entry = _package( + tmp_path, + home_exports=[item for item in ALL_NAMES if item != "counter"], + facade='from .home import counter\n\n__all__ = ["counter"]\n', + ) + + result = _plan(entry, tmp_path, "facade_only_counter") + generated = (result.output_dir / "facade_only_counter_wrapper.c").read_text(encoding="utf-8") + assert generated.count("static PyObject * module_get_counter(void) {") == 1 + assert generated.count("static int module_set_counter(PyObject * value_obj) {") == 1 -@pytest.mark.parametrize( - ("name", "kind"), - [("counter", "module variable"), ("area", "generic")], -) -def test_moving_either_kind_to_a_facade_alone_is_refused(name: str, kind: str, tmp_path: Path): - """Relocating publishes it in one place and still not where it lives. - Counting namespaces would accept this, because withholding the name at - home leaves exactly one publisher. +@pytest.mark.parametrize("home_exports", [ALL_NAMES, [item for item in ALL_NAMES if item != "area"]]) +def test_a_generic_cannot_be_published_from_a_facade(home_exports: list[str], tmp_path: Path): + """A generic is not one native entity that another namespace can bind. + + The restriction holds whether its declaring namespace also publishes it or + the facade is its only requested publication. """ entry = _package( tmp_path, - home_exports=[item for item in ALL_NAMES if item != name], - facade=f'from .home import {name}\n\n__all__ = ["{name}"]\n', + home_exports=home_exports, + facade='from .home import area\n\n__all__ = ["area"]\n', ) with pytest.raises(ValueError) as error: - _plan(entry, tmp_path, f"facade_only_{name}") + _plan(entry, tmp_path, "facade_area") - assert f"{kind} {name!r} is declared in home and published in facade" in str(error.value) + message = str(error.value) + assert "generic 'area' is declared in home and published in facade" in message + assert "publishable only by the namespace declaring it" in message def test_a_procedure_still_reaches_python_through_a_facade(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 3d48c6c5b..b678a2fc5 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 @@ -954,14 +954,8 @@ def test_a_published_intrinsic_name_states_no_contract_import(): assert '__all__ = ["rate"]' in code -def test_generated_contract_omits_a_republication_no_build_can_expose(): - """A contract states only what publishing can actually reach. - - A module variable holds state that stays live where it is declared, and a - generic is a dispatch surface rather than one object, so neither reaches a - second namespace. A source build publishes neither, and a contract written - from that source claims neither, which keeps the two builds agreeing. - """ +def test_generated_contract_publishes_a_module_variable_reexport(): + """A re-exporting contract names the declaring variable as public state.""" parsed = parse_fortran_source(""" module state_home implicit none @@ -988,5 +982,5 @@ def test_generated_contract_omits_a_republication_no_build_can_expose(): ("bump", "procedure"), ("counter", "variable"), ] - # The procedure is publishable; the live variable stays where it is declared. - assert stubs["state_facade"].rstrip().endswith('__all__ = ["bump"]') + assert "from .state_home import counter, bump" in stubs["state_facade"] + assert stubs["state_facade"].rstrip().endswith('__all__ = ["counter", "bump"]') 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 38f4227f6..91f3df2a5 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_generated_pyi_and_import, _build_source_and_import, _build_source_or_generated_pyi_and_import, _build_text_and_import, @@ -19,6 +20,47 @@ pytestmark = pytest.mark.fortran_end_to_end +MODULE_VARIABLE_REEXPORT_SOURCE = """ +module reexport_state_home + use iso_fortran_env, only: int32, real64 + implicit none + + type :: item + integer(int32) :: value = 0 + end type item + + integer(int32), parameter :: limit = 7 + integer(int32) :: counter = 3 + integer(int32) :: numbers(3) + real(real64), allocatable :: values(:) + real(real64), target :: backing(3) + real(real64), pointer :: selected(:) => null() + type(item) :: current + type(item), allocatable :: optional_item + +contains + + subroutine setup() + numbers = [1, 2, 3] + if (.not. allocated(values)) allocate(values(3)) + values = [4.0_real64, 5.0_real64, 6.0_real64] + backing = [7.0_real64, 8.0_real64, 9.0_real64] + selected => backing + current%value = 10 + if (.not. allocated(optional_item)) allocate(optional_item) + optional_item%value = 11 + end subroutine setup +end module reexport_state_home + +module reexport_state_facade + use reexport_state_home, only: limit, counter, numbers, values, selected, current, optional_item + implicit none + private + public :: limit, counter, numbers, values, selected, current, optional_item +end module reexport_state_facade +""" + + def _module_variables_build_dir(tmp_path: Path, build_mode: str) -> Path: if build_mode == "source": return tmp_path / "source_build" @@ -620,6 +662,100 @@ def test_declared_length_character_module_arrays_compile_and_expose_their_width( """ +def test_module_variable_reexports_share_one_native_entity_from_source_and_contract( + pyi_parity_build_mode: str, + tmp_path: Path, +): + """Every publication reads one variable plan and its live native state.""" + source = tmp_path / "module_variable_reexports.f90" + source.write_text(MODULE_VARIABLE_REEXPORT_SOURCE, encoding="utf-8") + + if pyi_parity_build_mode == "source": + build_dir = tmp_path / "source_build" + module = _build_source_and_import( + source, + build_dir, + { + "bind_c_module_variable_reexports_wrapper.f90", + "module_variable_reexports_wrapper.c", + "module_variable_reexports_wrapper.h", + }, + ) + else: + workdir = tmp_path / "generated_pyi_build" + module = _build_generated_pyi_and_import(source, workdir) + build_dir = workdir / "pyi_build" + + contracts = workdir / "contracts" / source.stem + home_contract = (contracts / "reexport_state_home.pyi").read_text(encoding="utf-8") + facade_contract = (contracts / "reexport_state_facade.pyi").read_text(encoding="utf-8") + assert "counter: Int32" in home_contract + assert "limit: Final[Int32]" in home_contract + assert "from .reexport_state_home import " in facade_contract + imported_names = facade_contract.partition("import ")[2].partition("\n")[0].split(", ") + assert {"counter", "limit"}.issubset(imported_names) + assert '"counter"' in facade_contract.partition("__all__ = ")[2] + assert '"limit"' in facade_contract.partition("__all__ = ")[2] + + home = module.reexport_state_home + facade = module.reexport_state_facade + home.setup() + + assert home.limit == facade.limit == np.int32(7) + + home.counter = np.int32(10) + assert facade.counter == np.int32(10) + facade.counter = np.int32(25) + assert home.counter == np.int32(25) + + home.numbers[0] = np.int32(21) + assert facade.numbers[0] == np.int32(21) + facade.numbers[1] = np.int32(22) + assert home.numbers[1] == np.int32(22) + + home.values.to_numpy()[0] = np.float64(31.0) + assert facade.values.to_numpy()[0] == np.float64(31.0) + facade.values.to_numpy()[1] = np.float64(32.0) + assert home.values.to_numpy()[1] == np.float64(32.0) + + assert home.selected.associated is True + facade.selected.nullify() + assert home.selected.associated is False + + home.current.value = np.int32(41) + assert facade.current.value == np.int32(41) + facade.current.value = np.int32(42) + assert home.current.value == np.int32(42) + + home.optional_item.value = np.int32(51) + assert facade.optional_item.value == np.int32(51) + facade.optional_item.value = np.int32(52) + assert home.optional_item.value == np.int32(52) + + generated = next(build_dir.glob("*_wrapper.c")).read_text(encoding="utf-8") + assert "module_get_limit" not in generated + assert "module_set_limit" not in generated + for name in ("counter", "numbers", "values", "selected", "current", "optional_item"): + assert generated.count(f"static PyObject * module_get_{name}(void) {{") == 1 + assert generated.count("static int module_set_counter(PyObject * value_obj) {") == 1 + + bridge = next(build_dir.glob("bind_c_*_wrapper.f90")).read_text(encoding="utf-8") + assert "bind_c_get_limit" not in bridge + assert "bind_c_set_limit" not in bridge + for signature in ( + "function bind_c_get_counter(", + "subroutine bind_c_set_counter(", + "function bind_c_get_numbers(", + "subroutine bind_c_values_descriptor(", + "subroutine bind_c_selected_descriptor(", + "function bind_c_prik_module_field_current_value_get(", + "subroutine bind_c_prik_module_field_current_value_set(", + "function bind_c_prik_module_field_optional_item_value_get(", + "subroutine bind_c_prik_module_field_optional_item_value_set(", + ): + assert bridge.count(signature) == 1 + + 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. From b35ce8483949efd07489f44d45011fd9be54c4b4 Mon Sep 17 00:00:00 2001 From: said Date: Wed, 16 Sep 2026 20:12:17 +0100 Subject: [PATCH 35/96] Carry the bundled helpers a namespace alias binds through A module deciding whether it needs the bundled native helpers counted module variables, function arguments and results, and derived-type fields. Binding a re-exported name calls one of those helpers too, so a module whose alias was its only use of them was judged to need none: the generated extension called prik_bind_namespace_alias without carrying it and failed to import. Count the aliases as well. The regression test publishes a module whose procedures take no arguments and return nothing, so nothing but the alias asks for the helpers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 6 ++ prik/codegen/c/binding.py | 3 + .../end_to_end/test_reexport_alias_linking.py | 61 +++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/test_reexport_alias_linking.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e3653f2e0..067766927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ release tags add a leading `v` to the package version. ## Unreleased +- A module publishing a re-exported name links again when that alias is the only + thing it needs a bundled helper for. Binding an alias calls one, but a module + with no arguments, results, module variables or derived-type fields was + treated as needing none, so the generated extension referenced + `prik_bind_namespace_alias` without carrying it and failed to import. + - A module-variable re-export now publishes another live route to the declaring variable instead of being omitted or rejected. Every namespace reuses one completed variable plan and its native accessors, so scalar assignment, array diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index c0ef6365c..ad9a8d6a4 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -670,6 +670,9 @@ def requires_native_support(self, plan: ModulePlan) -> bool: # Every published component converts through the bundled helpers, so a # type whose module exposes only `bind(C)` procedures still needs them. or any(derived.fields for derived in self._derived_types(plan)) + # A namespace alias binds its target through a bundled helper, which a + # module publishing nothing else would otherwise never include. + or any(namespace.aliases for namespace in plan.namespaces) ) def _module_needs_allocator(self, plan: ModulePlan) -> bool: diff --git a/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/test_reexport_alias_linking.py b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/test_reexport_alias_linking.py new file mode 100644 index 000000000..e8d2620d4 --- /dev/null +++ b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/test_reexport_alias_linking.py @@ -0,0 +1,61 @@ +"""A namespace alias reaches its target in a module that publishes nothing else. + +Binding an alias calls a bundled native helper, so a module whose only use of +those helpers is the alias still has to carry them. Nothing else in such a +module asks for the support header, which is what made this fail to link. +""" + +from pathlib import Path + +import pytest + +from prik.pipeline.build import build_fortran_extension +from tests.fortran._support.wrapper_build import _import_from_build_dir + +pytestmark = pytest.mark.fortran_end_to_end + +# Neither procedure takes an argument, returns a result, or touches a derived +# type or module variable, so the alias is the module's only helper use. +HOME = """\ +module alias_home + implicit none +contains + subroutine target() + end subroutine target +end module alias_home +""" + +FACADE = """\ +module alias_facade + use alias_home, only : lambda => target + implicit none + public :: lambda + +contains + + subroutine lambda_() + end subroutine lambda_ +end module alias_facade +""" + + +def test_an_alias_is_the_only_helper_a_module_needs(tmp_path: Path): + """The alias binds its target, so the module links and both names resolve.""" + home = tmp_path / "alias_home.f90" + home.write_text(HOME, encoding="utf-8") + facade = tmp_path / "alias_facade.f90" + facade.write_text(FACADE, encoding="utf-8") + + result = build_fortran_extension( + [home, facade], + output_dir=tmp_path / "build", + output_name="alias_api", + ) + module = _import_from_build_dir(result.module_name, result.output_dir) + + published = {name for name in dir(module.alias_facade) if not name.startswith("_")} + assert published == {"lambda_", "lambda__2"} + # The module's own declaration keeps the name it would have had, and the + # imported alias is the one export policy moved aside. + assert module.alias_facade.lambda_.__name__ == "lambda_" + assert module.alias_facade.lambda__2 is module.alias_home.target From 54e73a64516e8c4f316272f3a386fe339df5bc79 Mon Sep 17 00:00:00 2001 From: said Date: Wed, 16 Sep 2026 22:03:38 +0100 Subject: [PATCH 36/96] Own a re-exported variable without publishing where it is declared A contract may hide the module declaring a variable and publish it only through a facade. The one native variable plan was still keyed into the declaring namespace, and the planner builds its namespace list from those keys, so holding the plan put that module into Python: a package stating only the facade exposed the declaring module beside it. Ownership now follows a namespace that publishes the variable when the declaring one does not, so native ownership never creates a Python namespace of its own. There is still one plan, one set of accessors, and one allocation and association state; a publication adds names. A published `parameter` is documented as what it is rather than as a read-only constant: it has no native storage to share, assignment is not refused, and rebinding one namespace changes neither the Fortran parameter nor any other namespace publishing it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 11 + docs/user/guide/wrapping-modules.md | 10 +- .../pyi-contracts/exports-and-modules.md | 15 +- prik/planning/planner.py | 33 +- .../test_module_variable_reexport.py | 292 ++++++++++++++++++ 5 files changed, 347 insertions(+), 14 deletions(-) create mode 100644 tests/fortran/modules/end_to_end/test_module_variable_reexport.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 067766927..60d994b04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ release tags add a leading `v` to the package version. ## Unreleased +- A contract may publish a module variable only through a facade, leaving the + namespace declaring it out of Python entirely. Owning the one native variable + plan used to put that namespace there anyway, so a package hiding its + declaring module exposed it regardless. + +- A published Fortran `parameter` is documented as what it is. Each namespace + receives the declared value as an ordinary Python attribute: assignment is + not refused, rebinding one name leaves the Fortran parameter unchanged, and + it does not rebind any other namespace publishing the same parameter. The + reference previously called this a read-only constant. + - A module publishing a re-exported name links again when that alias is the only thing it needs a bundled helper for. Binding an alias calls one, but a module with no arguments, results, module variables or derived-type fields was diff --git a/docs/user/guide/wrapping-modules.md b/docs/user/guide/wrapping-modules.md index 6b533ca5d..7c7201b6d 100644 --- a/docs/user/guide/wrapping-modules.md +++ b/docs/user/guide/wrapping-modules.md @@ -103,12 +103,14 @@ mod.counter = np.int32(9) print(mod.counter) # 9 print(mod.summarize()) # 21 -print(mod.nmax) # 12 (read-only parameter) +print(mod.nmax) # 12 (the declared parameter value) ``` -- `parameter` declarations become read-only constants in the generated - contract. -- Assigning to a constant in Python only creates a local shadow — it does **not** mutate the native value. +- `parameter` declarations become `Final[...]` constants in the generated + contract, carrying the value the Fortran `parameter` declares. +- Assignment is not refused. Assigning to one rebinds that Python name only: it + does **not** mutate the native value, and it does not change any other + namespace publishing the same parameter. --- diff --git a/docs/user/reference/pyi-contracts/exports-and-modules.md b/docs/user/reference/pyi-contracts/exports-and-modules.md index c69df47a6..ad9acdf82 100644 --- a/docs/user/reference/pyi-contracts/exports-and-modules.md +++ b/docs/user/reference/pyi-contracts/exports-and-modules.md @@ -184,8 +184,19 @@ and derived-object changes made through either namespace are immediately visible through the other. PRIK completes the variable's access and ownership policy once; the second namespace changes publication only. -A re-exported `Final[...]` parameter remains the same read-only constant. It -does not gain setter or storage machinery. +A re-exported `Final[...]` parameter behaves differently, because a Fortran +`parameter` has no native storage to share and no setter. Each namespace +receives the same native constant value as an ordinary Python attribute: + +- every publication starts at the value the Fortran `parameter` declares; +- assigning to one, such as `facade.limit`, rebinds that Python name and does + not modify the Fortran parameter; +- assigning to one does not rebind the others, so the namespaces can disagree + afterwards. + +Assignment is not refused. Nothing enforces the constant at runtime, so treat +a published parameter as a value each namespace holds rather than a shared +read-only view of native state. ## Next diff --git a/prik/planning/planner.py b/prik/planning/planner.py index fb968f9c3..5ac988284 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -1154,14 +1154,7 @@ def _variables_by_namespace( exports_by_namespace = defaultdict(list) for export in policy.python_exports: exports_by_namespace[export.namespace].append(export.name) - native_namespace = tuple(part.casefold() for part in str(policy.native_module).split(".") if part) - declaring_namespace = ( - native_namespace - if native_namespace in exports_by_namespace - else () - if () in exports_by_namespace and str(policy.native_module).casefold() == module.name.casefold() - else native_namespace - ) + declaring_namespace = self._canonical_variable_namespace(policy, module.name, set(exports_by_namespace)) declaring_names = tuple(exports_by_namespace.get(declaring_namespace, ())) or (policy.name,) plan = self._module_variable_plan( policy, @@ -1179,6 +1172,30 @@ def _variables_by_namespace( ) return variables, publications + @staticmethod + def _canonical_variable_namespace( + policy, + module_name: str, + exported: set[tuple[str, ...]], + ) -> tuple[str, ...]: + """Return the namespace the one native variable plan is owned by. + + Ownership follows the namespace declaring the variable wherever that + namespace publishes it. A contract may publish a variable only through + a facade, though, and native ownership must not put the declaring + namespace into Python merely to hold the plan, so ownership moves to a + namespace that is published. The choice is the least path so one plan + owns the variable whichever order namespaces are walked in. + """ + native_namespace = tuple(part.casefold() for part in str(policy.native_module).split(".") if part) + if native_namespace in exported: + return native_namespace + if () in exported and str(policy.native_module).casefold() == module_name.casefold(): + return () + if exported: + return min(exported) + return native_namespace + def _complete_generated_symbols( self, functions: dict[tuple[str, ...], list[FunctionPlan]], diff --git a/tests/fortran/modules/end_to_end/test_module_variable_reexport.py b/tests/fortran/modules/end_to_end/test_module_variable_reexport.py new file mode 100644 index 000000000..d5c3b75d8 --- /dev/null +++ b/tests/fortran/modules/end_to_end/test_module_variable_reexport.py @@ -0,0 +1,292 @@ +"""A re-exported module variable is another route to one native variable. + +Publishing a variable in a second namespace adds Python names only. The native +storage, accessors, allocation state, and pointer association stay single, so +every publication observes the same changes whichever one made them. +""" + +from pathlib import Path + +import numpy as np +import pytest + +from prik.pipeline.build import build_fortran_extension, build_pyi_extension +from tests.fortran._support.wrapper_build import _generate_checked_pyi_contract, _import_from_build_dir + +pytestmark = pytest.mark.fortran_end_to_end + +SOURCE = """\ +module store_mod + implicit none + integer :: counter = 5 + integer, parameter :: limit = 42 + character(len=8) :: label = 'first ' + character(len=4) :: tags(2) = ['ab ', 'cd '] + real(8), allocatable :: values(:) + real(8), pointer :: view(:) => null() + real(8), target :: backing(4) = [1.0d0, 2.0d0, 3.0d0, 4.0d0] +contains + subroutine allocate_values(n) + integer, intent(in) :: n + if (allocated(values)) deallocate(values) + allocate(values(n)) + values = 1.0d0 + end subroutine allocate_values + + subroutine release_values() + if (allocated(values)) deallocate(values) + end subroutine release_values + + subroutine associate_view() + view => backing + end subroutine associate_view + + subroutine clear_view() + view => null() + end subroutine clear_view + +end module store_mod + +module facade_mod + use store_mod, only : counter, limit, label, tags, values, view + implicit none + public :: counter, limit, label, tags, values, view +end module facade_mod + +module renamed_mod + use store_mod, only : tally => counter + implicit none + public :: tally +end module renamed_mod + +module hop_mod + use renamed_mod, only : tally + implicit none + public :: tally +end module hop_mod +""" + + +@pytest.fixture(scope="module") +def built(tmp_path_factory): + """Build the shared source project once for the read-only checks.""" + tmp_path = tmp_path_factory.mktemp("variable_reexport") + source = tmp_path / "store.f90" + source.write_text(SOURCE, encoding="utf-8") + result = build_fortran_extension(source, output_dir=tmp_path / "build", output_name="store_api") + return _import_from_build_dir(result.module_name, result.output_dir) + + +def test_a_scalar_publication_reads_and_writes_one_native_variable(built): + """Both namespaces name the same storage, so either one observes the other.""" + built.store_mod.counter = np.int32(11) + assert built.facade_mod.counter == np.int32(11) + + built.facade_mod.counter = np.int32(23) + assert built.store_mod.counter == np.int32(23) + + +def test_a_renamed_publication_reaches_the_same_variable(built): + """A rename changes the Python name a namespace binds, never the variable.""" + built.store_mod.counter = np.int32(31) + assert built.renamed_mod.tally == np.int32(31) + + built.renamed_mod.tally = np.int32(37) + assert built.store_mod.counter == np.int32(37) + + +def test_a_multi_hop_publication_resolves_to_the_declaring_variable(built): + """A -> B -> C publishes what A declares, not a copy B made.""" + built.store_mod.counter = np.int32(41) + assert built.hop_mod.tally == np.int32(41) + + built.hop_mod.tally = np.int32(43) + assert built.store_mod.counter == np.int32(43) + assert built.renamed_mod.tally == np.int32(43) + + +def test_a_character_scalar_and_array_publish_one_storage(built): + """String storage is shared the same way a scalar is.""" + built.store_mod.label = "second " + assert built.facade_mod.label == "second " + + built.store_mod.tags[0] = b"zz " + assert bytes(built.facade_mod.tags[0]) == b"zz " + + +def test_allocation_state_is_one_state_for_every_publication(built): + """Allocating through the declaring module is visible through the facade.""" + built.store_mod.release_values() + assert built.facade_mod.values.allocated is False + + built.store_mod.allocate_values(np.int32(3)) + assert built.facade_mod.values.allocated is True + assert built.facade_mod.values.shape == (3,) + + # Reallocating to another extent replaces the one descriptor both see. + built.store_mod.allocate_values(np.int32(5)) + assert built.facade_mod.values.shape == (5,) + + built.store_mod.release_values() + assert built.facade_mod.values.allocated is False + + +def test_pointer_association_is_one_association_for_every_publication(built): + """Associating and nullifying reach every namespace publishing the pointer.""" + built.store_mod.clear_view() + assert built.facade_mod.view.associated is False + + built.store_mod.associate_view() + assert built.facade_mod.view.associated is True + assert built.facade_mod.view.shape == (4,) + + built.store_mod.clear_view() + assert built.facade_mod.view.associated is False + + +def test_publishing_a_protected_variable_keeps_the_documented_refusal(tmp_path: Path): + """A second namespace adds names, so it cannot make an unsupported form work. + + PRIK refuses `protected` because a generated accessor cannot define the + variable outside its own module, and re-exporting it changes nothing about + that. + """ + source = tmp_path / "guarded.f90" + source.write_text( + """\ +module guard_mod + implicit none + integer, protected :: guarded = 9 +end module guard_mod + +module guard_facade + use guard_mod, only : guarded + implicit none + public :: guarded +end module guard_facade +""", + encoding="utf-8", + ) + + with pytest.raises(ValueError) as error: + build_fortran_extension( + source, + output_dir=tmp_path / "build", + output_name="guard_api", + generate_sources=True, + ) + + assert "is PROTECTED" in str(error.value) + + +def test_a_parameter_publishes_a_value_rather_than_shared_storage(built): + """A `parameter` has no storage to share, so each namespace holds the value. + + Assignment is not refused, and rebinding one name changes neither the + Fortran parameter nor any other namespace publishing it. + """ + assert built.store_mod.limit == np.int32(42) + assert built.facade_mod.limit == np.int32(42) + + built.facade_mod.limit = np.int32(7) + + assert built.facade_mod.limit == np.int32(7) + assert built.store_mod.limit == np.int32(42) + + +def test_one_native_accessor_serves_every_publication(tmp_path: Path): + """The second namespace adds names, so no second accessor is generated.""" + source = tmp_path / "store.f90" + source.write_text(SOURCE, encoding="utf-8") + + result = build_fortran_extension( + source, + output_dir=tmp_path / "generated", + output_name="accessor_api", + generate_sources=True, + ) + wrapper = (result.output_dir / "accessor_api_wrapper.c").read_text(encoding="utf-8") + + # One getter and one setter definition carry `counter`, however many + # namespaces publish it; the four dispatches all call the same pair. + assert wrapper.count("static PyObject * module_get_counter(void) {") == 1 + assert wrapper.count("static int module_set_counter(PyObject * value_obj) {") == 1 + assert wrapper.count("return module_get_counter();") == 4 + + +def test_a_facade_may_publish_a_variable_its_declaring_namespace_hides(tmp_path: Path): + """Owning the one variable plan must not put the declaring module in Python.""" + source = tmp_path / "store.f90" + source.write_text( + """\ +module home_mod + implicit none + integer :: counter = 5 +end module home_mod +""", + encoding="utf-8", + ) + package = tmp_path / "contracts" + package.mkdir() + (package / "home_mod.pyi").write_text( + "from prik.contracts import Int32\n\ncounter: Int32\n\n__all__ = []\n", + encoding="utf-8", + ) + (package / "facade.pyi").write_text( + 'from .home_mod import counter\n\n__all__ = ["counter"]\n', + encoding="utf-8", + ) + (package / "__init__.pyi").write_text( + 'from . import facade\n\n__all__ = ["facade"]\n', + encoding="utf-8", + ) + + result = build_pyi_extension( + package / "__init__.pyi", + native_fortran_sources=[str(source)], + output_dir=tmp_path / "build", + output_name="hidden_home_api", + ) + module = result.import_module() + + assert module.facade.counter == np.int32(5) + module.facade.counter = np.int32(17) + assert module.facade.counter == np.int32(17) + # The declaring module publishes nothing, so it is not a Python namespace. + assert not hasattr(module, "home_mod") + + +def test_a_generated_contract_publishes_the_same_variables_as_its_source(tmp_path: Path): + """Both routes reach one variable, so the two builds publish the same surface.""" + source = tmp_path / "store.f90" + source.write_text(SOURCE, encoding="utf-8") + + source_result = build_fortran_extension( + source, + output_dir=tmp_path / "source_build", + output_name="parity_source", + ) + contracts = tmp_path / "contracts" + _generate_checked_pyi_contract(source, contracts, None) + contract_result = build_pyi_extension( + contracts / "__init__.pyi", + native_fortran_sources=[str(source)], + output_dir=tmp_path / "contract_build", + output_name="parity_contract", + ) + + from_source = _import_from_build_dir(source_result.module_name, source_result.output_dir) + from_contract = _import_from_build_dir(contract_result.module_name, contract_result.output_dir) + + def surface(module): + return { + namespace: sorted(n for n in dir(getattr(module, namespace)) if not n.startswith("_")) + for namespace in ("store_mod", "facade_mod", "renamed_mod", "hop_mod") + } + + assert surface(from_source) == surface(from_contract) + + # The contract route reaches the same native variable, not a copy of it. + from_contract.facade_mod.counter = np.int32(61) + assert from_contract.store_mod.counter == np.int32(61) + assert from_contract.hop_mod.tally == np.int32(61) From b62216f5679382d27323db164f6aed352d33eb65 Mon Sep 17 00:00:00 2001 From: said Date: Wed, 16 Sep 2026 22:03:54 +0100 Subject: [PATCH 37/96] Name the bspline classes the way the wrapper now publishes them A wrapped type is spelled like the Python class it becomes, so the example's reviewed inventory, its tests and its two pages name `Bspline_1d` through `Bspline_6d` and `Bspline_Class`. The Fortran spellings and the abstract instantiation error stay as written, because that message names the native type rather than the published class. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- docs/user/examples/fortran/bspline-wrapper.md | 6 +++--- examples/fortran/bspline/README.md | 6 +++--- examples/fortran/bspline/routine_inventory.py | 14 +++++++------- .../bspline/tests/test_object_oriented_api.py | 14 +++++++------- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/user/examples/fortran/bspline-wrapper.md b/docs/user/examples/fortran/bspline-wrapper.md index 0bebab1e3..d9338eb04 100644 --- a/docs/user/examples/fortran/bspline-wrapper.md +++ b/docs/user/examples/fortran/bspline-wrapper.md @@ -135,7 +135,7 @@ import numpy as np import prik_bspline.bspline_oo_module as bspline x = np.linspace(0.0, 2.0 * np.pi, 25) -spline = bspline.bspline_1d(x, np.sin(x), np.int32(4)) +spline = bspline.Bspline_1d(x, np.sin(x), np.int32(4)) value, iflag = spline.evaluate(np.float64(1.234), np.int32(0)) area, iflag = spline.integral(np.float64(0.0), np.float64(np.pi)) @@ -145,11 +145,11 @@ The abstract base is exported but cannot be constructed. Its concrete extensions inherit the base bindings and answer its deferred operations: ```python -bspline.bspline_class() +bspline.Bspline_Class() # TypeError: bspline_class is an abstract native type and cannot be # instantiated; create one of its concrete extensions instead -issubclass(bspline.bspline_1d, bspline.bspline_class) # True +issubclass(bspline.Bspline_1d, bspline.Bspline_Class) # True ``` The procedural module exposes the matching `db1ink` through `db6ink` setup diff --git a/examples/fortran/bspline/README.md b/examples/fortran/bspline/README.md index fe906d709..5ae3eecc7 100644 --- a/examples/fortran/bspline/README.md +++ b/examples/fortran/bspline/README.md @@ -84,7 +84,7 @@ import numpy as np import prik_bspline.bspline_oo_module as bspline x = np.linspace(0.0, 2.0 * np.pi, 25) -spline = bspline.bspline_1d(x, np.sin(x), np.int32(4)) # generic constructor +spline = bspline.Bspline_1d(x, np.sin(x), np.int32(4)) # generic constructor value, iflag = spline.evaluate(np.float64(1.234), np.int32(0)) print(value) # about 0.943811 @@ -96,11 +96,11 @@ print(area) # about 2.0 The abstract base is present but cannot be constructed: ```python -bspline.bspline_class() +bspline.Bspline_Class() # TypeError: bspline_class is an abstract native type and cannot be # instantiated; create one of its concrete extensions instead -issubclass(bspline.bspline_1d, bspline.bspline_class) # True +issubclass(bspline.Bspline_1d, bspline.Bspline_Class) # True ``` ## Run focused tests diff --git a/examples/fortran/bspline/routine_inventory.py b/examples/fortran/bspline/routine_inventory.py index 7bbe972c8..8ec6cb9df 100644 --- a/examples/fortran/bspline/routine_inventory.py +++ b/examples/fortran/bspline/routine_inventory.py @@ -4,15 +4,15 @@ #: Object-oriented classes, most-derived first, over one abstract base. CLASSES: tuple[str, ...] = ( - "bspline_1d", - "bspline_2d", - "bspline_3d", - "bspline_4d", - "bspline_5d", - "bspline_6d", + "Bspline_1d", + "Bspline_2d", + "Bspline_3d", + "Bspline_4d", + "Bspline_5d", + "Bspline_6d", ) -ABSTRACT_BASE = "bspline_class" +ABSTRACT_BASE = "Bspline_Class" #: Bindings the abstract base declares and every class answers. DEFERRED_BINDINGS: tuple[str, ...] = ("destroy", "size_of") diff --git a/examples/fortran/bspline/tests/test_object_oriented_api.py b/examples/fortran/bspline/tests/test_object_oriented_api.py index 7ed4451c8..1f23c0fe9 100644 --- a/examples/fortran/bspline/tests/test_object_oriented_api.py +++ b/examples/fortran/bspline/tests/test_object_oriented_api.py @@ -19,7 +19,7 @@ def _sine_spline(bspline_oo, points=25): x = np.linspace(0.0, 2.0 * np.pi, points) - spline = bspline_oo.bspline_1d(x, np.sin(x), CUBIC) + spline = bspline_oo.Bspline_1d(x, np.sin(x), CUBIC) assert spline.status_ok() return spline @@ -43,11 +43,11 @@ def test_every_reviewed_class_is_exported(bspline_oo): def test_abstract_base_cannot_be_instantiated(bspline_oo): """`bspline_class` is declared abstract, so only its extensions have instances.""" with pytest.raises(TypeError, match="abstract native type and cannot be instantiated"): - bspline_oo.bspline_class() + bspline_oo.Bspline_Class() def test_every_class_extends_the_abstract_base(bspline_oo): - base = bspline_oo.bspline_class + base = bspline_oo.Bspline_Class for name in CLASSES: assert issubclass(getattr(bspline_oo, name), base), name @@ -56,7 +56,7 @@ def test_every_class_extends_the_abstract_base(bspline_oo): def test_every_concrete_class_interpolates_an_affine_grid(bspline_oo, dimension): """Every dimension-specific constructor and evaluator works end to end.""" axes, values = _affine_grid(dimension) - spline = getattr(bspline_oo, f"bspline_{dimension}d")(*axes, values, *(CUBIC,) * dimension) + spline = getattr(bspline_oo, f"Bspline_{dimension}d")(*axes, values, *(CUBIC,) * dimension) value, iflag = spline.evaluate(*(np.float64(0.3),) * dimension, *(np.int32(0),) * dimension) @@ -74,7 +74,7 @@ def test_every_class_answers_the_deferred_and_inherited_bindings(bspline_oo): def test_generic_constructor_accepts_each_declared_signature(bspline_oo): """`interface bspline_1d` publishes an empty and a data-driven constructor.""" - empty = bspline_oo.bspline_1d() + empty = bspline_oo.Bspline_1d() assert empty.status_ok() is False spline = _sine_spline(bspline_oo) @@ -112,7 +112,7 @@ def test_two_dimensional_interpolation_matches_the_sampled_surface(bspline_oo): y = np.linspace(0.0, 1.0, 20) samples = np.asfortranarray(np.exp(-(x[:, None] ** 2 + y[None, :] ** 2))) - spline = bspline_oo.bspline_2d(x, y, samples, CUBIC, CUBIC) + spline = bspline_oo.Bspline_2d(x, y, samples, CUBIC, CUBIC) assert spline.status_ok() value, iflag = spline.evaluate(np.float64(0.33), np.float64(0.47), np.int32(0), np.int32(0)) @@ -124,7 +124,7 @@ def test_deferred_bindings_dispatch_through_the_abstract_base(bspline_oo): """The base declares `size_of` and `destroy`; the object's own type answers.""" spline = _sine_spline(bspline_oo) - assert bspline_oo.bspline_class.size_of(spline) == spline.size_of() + assert bspline_oo.Bspline_Class.size_of(spline) == spline.size_of() assert spline.size_of() > np.int32(0) spline.destroy() From c67df0209f81488029aa5f5e7d6757b2c44a7b78 Mon Sep 17 00:00:00 2001 From: said Date: Wed, 16 Sep 2026 22:04:13 +0100 Subject: [PATCH 38/96] Let the installed environment import what the wheel depends on The wheel is installed without its dependencies, so the new environment reads them from the interpreter that built it. Sharing the interpreter's system directories covers a system-wide install, but a development checkout commonly installs per-user, and the commands under test run isolated, which drops that directory. Every dependency was then missing and the environment could not import prik at all. Name the directories this interpreter actually found them in, inside the new environment's own site directory, which isolated mode still reads. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- .../_support/installed_distribution.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/fortran/_support/installed_distribution.py b/tests/fortran/_support/installed_distribution.py index 4a803a67e..291d4942b 100644 --- a/tests/fortran/_support/installed_distribution.py +++ b/tests/fortran/_support/installed_distribution.py @@ -6,7 +6,9 @@ wheel once per session keeps that evidence affordable. """ +import importlib import os +import site import subprocess import sys import venv @@ -86,9 +88,37 @@ def installed_prik_python() -> Path: ) if install.returncode != 0: pytest.fail(f"installing the built wheel failed:\n{install.stderr.strip() or install.stdout.strip()}") + _share_runtime_dependencies(environment_dir) return installed_python +def _share_runtime_dependencies(environment_dir: Path) -> None: + """Make the wheel's runtime dependencies importable in the new environment. + + The wheel is installed without its dependencies, so the environment reads + them from the interpreter that built it. ``system_site_packages`` shares + only the interpreter's system directories, and the commands under test run + isolated, which drops the per-user directory a development install commonly + writes to. The directories holding those dependencies are named here so the + environment resolves them wherever this interpreter found them. + """ + required = ("immutabledict", "numpy", "filelock") + roots = { + str(Path(module.__file__).resolve().parent.parent) + for module in (importlib.import_module(name) for name in required) + if module.__file__ + } + site_packages = tuple(Path(environment_dir).glob("lib/python*/site-packages")) + if not site_packages: + return + shared = [root for root in sorted(roots) if root not in _DEFAULT_SITE_DIRECTORIES] + if shared: + (site_packages[0] / "_prik_runtime_dependencies.pth").write_text("\n".join(shared) + "\n", encoding="utf-8") + + +_DEFAULT_SITE_DIRECTORIES = frozenset(site.getsitepackages()) + + def installed_run(*command: str) -> str: """Return what one command prints from inside the installed environment.""" result = subprocess.run(command, env=clean_environment(), capture_output=True, text=True) From 9493296d2354d3dc2c576727f95f63ed03a083ca Mon Sep 17 00:00:00 2001 From: said Date: Wed, 16 Sep 2026 22:04:36 +0100 Subject: [PATCH 39/96] Record a published name the way its source spells it What a contract published was keyed by a case-folded name, which is how a Fortran importer asks for it. A case-sensitive source spells two declarations apart, though, so `Foo` and `foo` shared one entry and whichever was recorded first answered for both. Record each name as written and read it exactly, falling back to a case-insensitive match so a Fortran importer still reaches its name under any spelling. Nothing reaches this today, because a C contract does not yet import from another one; the entry is simply no longer waiting to be wrong. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- prik/printers/pyi.py | 28 +++++++++++++++---- .../test_pyi_printer_imports_and_packages.py | 18 ++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index 7b6c5ad0f..36c011946 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -172,7 +172,7 @@ def settled(self, category: str, raw_name: object) -> str | None: def publish(self, raw_name: object, public_name: str) -> str: """Record the spelling this contract published one name under.""" if not self.public_namespace: - self.published_names.setdefault(str(raw_name).casefold(), public_name) + self.published_names.setdefault(str(raw_name), public_name) return public_name def normalized(self, raw_name: object) -> str: @@ -197,6 +197,24 @@ def _public_owner_key(owner: object) -> object: return id(owner) +def published_name(published: dict[str, str] | None, source: object) -> str | None: + """Return the spelling a contract published one source name under. + + A contract records the name exactly as its source spells it, so two + declarations a case-sensitive language keeps apart keep separate entries. + A case-insensitive source may still ask for either spelling, which the + fallback answers once no exact entry does. + """ + if not published: + return None + wanted = str(source) + exact = published.get(wanted) + if exact is not None: + return exact + folded = wanted.casefold() + return next((value for key, value in published.items() if key.casefold() == folded), None) + + # Publication of these kinds has no runtime form yet, so a generated contract # does not claim it. _UNPUBLISHABLE_REEXPORT_KINDS = frozenset({"generic"}) @@ -266,10 +284,10 @@ def published_names(self, module: SemanticModule) -> dict[str, str]: self._visit(module, context) names = dict(context.published_names) for prototype in module.prototypes: - names[str(prototype.name).casefold()] = str(prototype.name) + names[str(prototype.name)] = str(prototype.name) for reexport in module.reexports: if reexport.entity_kind == "prototype": - names[str(reexport.local_name).casefold()] = str(reexport.local_name) + names[str(reexport.local_name)] = str(reexport.local_name) return names def _emission_context(self, node) -> _PyiEmissionContext: @@ -541,7 +559,7 @@ def _overload_target_name(candidate: SemanticFunction, context: _PyiEmissionCont # The specific was named while this same contract was rendered, and a # collision may have moved that name aside, so the naming it settled on # is what the target has to state. - published = context.published_names.get(target.casefold()) + published = published_name(context.published_names, target) return published or context.normalized(target) def _visit_ProcedureOverloadSet( @@ -2100,7 +2118,7 @@ def _emit_import_item( # published name aside. source = PyiPrinter._public_import_name(item.source, public_names=public_names) if public_names and published_names: - source = published_names.get(item.source.casefold(), source) + source = published_name(published_names, item.source) or source # A name this module publishes is bound under the name export policy # completed for it, which a collision with one of this module's own # declarations may have moved aside. 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 b678a2fc5..4df6dc892 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 @@ -3,6 +3,7 @@ import pytest import prik.pipeline.pyi as pyi_pipeline from prik.parsers.fortran import parse_fortran_file as parse_fortran_source +from prik.printers.pyi import published_name from prik.printers import ( PyiPrinter, emit_module, @@ -984,3 +985,20 @@ def test_generated_contract_publishes_a_module_variable_reexport(): ] assert "from .state_home import counter, bump" in stubs["state_facade"] assert stubs["state_facade"].rstrip().endswith('__all__ = ["counter", "bump"]') + + +def test_two_spellings_a_case_sensitive_source_keeps_apart_publish_separately(): + """A contract records each source name as written, so neither displaces the other. + + Keying what a contract published by a folded name loses one of a pair only + a case-sensitive source distinguishes, and an importer then binds whichever + was recorded first. + """ + published = {"Foo": "Foo", "foo": "foo", "SCALE": "scale"} + + assert published_name(published, "Foo") == "Foo" + assert published_name(published, "foo") == "foo" + # A case-insensitive source still reaches its name under any spelling. + assert published_name(published, "scale") == "scale" + assert published_name(published, "Scale") == "scale" + assert published_name(published, "missing") is None From b232a0ab02751190d6566e28857c92ddc637b077 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 17 Sep 2026 01:45:11 +0100 Subject: [PATCH 40/96] codex: stabilize reexports and variable ownership --- CHANGELOG.md | 29 ++- docs/developer/packages/codegen/c-binding.md | 2 + .../packages/codegen/fortran-bridge.md | 2 + docs/developer/packages/planning.md | 28 +-- docs/user/reference/pyi-format.md | 7 +- prik/codegen/c/binding.py | 127 ++++++----- prik/codegen/c/python_surface.py | 27 ++- prik/codegen/docstrings.py | 10 +- prik/codegen/fortran/bridge.py | 93 ++++---- prik/pipeline/wrapper.py | 50 +++-- prik/planning/entrypoints.py | 13 +- prik/planning/models.py | 19 +- prik/planning/planner.py | 155 +++++++------ prik/printers/pyi.py | 11 +- prik/semantics/fortran2ir.py | 166 ++++++++++++-- .../codegen/test_derived_lowering.py | 2 +- .../codegen/test_scalar_actual_dummy_plan.py | 4 +- .../infrastructure/codegen/test_planner.py | 41 +++- .../printers/test_source_printers.py | 1 + .../test_pyi_printer_imports_and_packages.py | 4 + .../codegen/test_native_handle_planning.py | 4 +- .../test_module_array_view_lowering.py | 11 +- .../test_scalar_module_variable_lowering.py | 64 ++---- .../test_module_variables_and_state.py | 16 +- .../semantics/test_reexport_accessibility.py | 203 ++++++++++++++++++ .../pointers/codegen/test_pointer_lowering.py | 2 +- 26 files changed, 764 insertions(+), 327 deletions(-) create mode 100644 tests/fortran/modules/semantics/test_reexport_accessibility.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 60d994b04..5b6b5d7f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ release tags add a leading `v` to the package version. ## Unreleased +- A module variable's canonical wrapper plan is now owned by its declaring + native module and name. Adding, removing, or renaming Python facades changes + only namespace publications, so support-operation and holder identities no + longer move between facades. + +- Generated Fortran contracts distinguish a module's dependencies from its + re-exports. An implicitly accessible imported name used by that module's own + declarations remains available to express them but is not published from the + importing module; naming it in a `public` statement still publishes it. + - A contract may publish a module variable only through a facade, leaving the namespace declaring it out of Python entirely. Owning the one native variable plan used to put that namespace there anyway, so a package hiding its @@ -60,7 +70,9 @@ release tags add a leading `v` to the package version. lost that name -- `BarBaz` reached Python as `barbaz` -- and invented collisions the source does not have: `Foo` and `foo` are two functions, and they arrived as `foo` and `foo_2` with nothing to say which was which. This - changes the published names of existing C wrappers. + changes the published names of existing C wrappers. Folded import lookup now + succeeds only when it identifies one declaration, rather than selecting an + ambiguous spelling by insertion order. - The contract a source build writes beside its artifacts states published Python names. It stated raw source spellings, so a Fortran build wrote @@ -84,10 +96,11 @@ release tags add a leading `v` to the package version. - A contract states everything it publishes in a closing `__all__`. An import cannot say whether a name is needed to express a declaration or meant to be published, because a rename reads the same either way, so the list settles it. - PRIK writes what the source publishes -- the module's own public declarations - and any imported name a `public` statement names -- and the list is there to - be edited: remove a name to stop publishing it, add an imported one to publish - it, or remove the list to publish everything the contract reaches. Reading C + PRIK writes what the source publishes -- the module's own public declarations, + explicitly public imports, and implicitly accessible imports that are not + declaration dependencies -- and the list is there to be edited: remove a name + to stop publishing it, add an imported one to publish it, or remove the list + to publish everything the contract reaches. Reading C source states the same thing through `--export-symbols` / `build_c_extension(export_symbols=...)`, which selects the source-side public surface and writes the corresponding Python names into the generated @@ -124,9 +137,9 @@ release tags add a leading `v` to the package version. 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 +- A plain `use` now re-exports the accessible names it carries unless a name is + only a dependency of the importing module's declarations. An explicit + `public` statement still publishes that dependency; an origin that two used modules could supply stays unresolved rather than guessed. - A generic interface built from several blocks merges within the scope diff --git a/docs/developer/packages/codegen/c-binding.md b/docs/developer/packages/codegen/c-binding.md index 6365e081c..86a614fea 100644 --- a/docs/developer/packages/codegen/c-binding.md +++ b/docs/developer/packages/codegen/c-binding.md @@ -125,6 +125,7 @@ plan = ModulePlan( binding=BindingModulePlan(...), entrypoint=NativeEntrypointModulePlan(...), bridge=BridgeModulePlan(...), + variables=(), namespaces=(namespace,), ) @@ -196,6 +197,7 @@ plan = ModulePlan( binding=BindingModulePlan(owner_path="demo"), entrypoint=NativeEntrypointModulePlan(owner_path="demo"), bridge=BridgeModulePlan(owner_path="demo"), + variables=(), namespaces=(namespace,), native_generated_code_groups=( NativeGeneratedCodeGroupPlan( diff --git a/docs/developer/packages/codegen/fortran-bridge.md b/docs/developer/packages/codegen/fortran-bridge.md index edf3a0f30..3984433dc 100644 --- a/docs/developer/packages/codegen/fortran-bridge.md +++ b/docs/developer/packages/codegen/fortran-bridge.md @@ -105,6 +105,7 @@ plan = ModulePlan( binding=BindingModulePlan(...), entrypoint=NativeEntrypointModulePlan(...), bridge=BridgeModulePlan(...), + variables=(), namespaces=(namespace,), ) @@ -176,6 +177,7 @@ plan = ModulePlan( binding=BindingModulePlan(owner_path="demo"), entrypoint=NativeEntrypointModulePlan(owner_path="demo"), bridge=BridgeModulePlan(owner_path="demo"), + variables=(), namespaces=(namespace,), native_generated_code_groups=( NativeGeneratedCodeGroupPlan( diff --git a/docs/developer/packages/planning.md b/docs/developer/packages/planning.md index ecb7e7162..85fa0b4f1 100644 --- a/docs/developer/packages/planning.md +++ b/docs/developer/packages/planning.md @@ -64,6 +64,7 @@ ModulePlan │ └── NativeEntrypointSignaturePlan ├── NativeGeneratedCodeGroupPlan (zero or more) ├── BridgeModulePlan (optional; Fortran-local holder inventories) +├── ModuleVariablePlan (canonical native-variable registry) └── NamespacePlan (root and child namespaces) ├── FunctionPlan │ ├── ArgumentTransferPlan @@ -72,8 +73,7 @@ ModulePlan │ ├── NativeEntrypointProjectedSlotPlan │ │ └── BridgeCallSlotPlan (optional adapter facet) │ └── LifecycleActionPlan - └── ModuleVariablePlan - └── ModuleVariablePublicationPlan (one or more namespace bindings) + └── ModuleVariablePublicationPlan (namespace bindings to canonical variables) ``` Each callable, argument, and result always owns binding and entrypoint views; @@ -84,13 +84,15 @@ projection, presence and length fields, descriptors, and hidden outputs. Bridge records own adapter-local representation conversion and the invocation of the original Fortran procedure. -One `ModuleVariablePlan` owns each declaring native variable and its completed -getter, setter, ownership, descriptor, array, and derived-object mechanisms. -`ModuleVariablePublicationPlan` records only a namespace and Python names that -publish that plan. Re-exporting module state therefore adds publication records -without adding variable plans, accessors, support procedures, initialization, -allocation state, or pointer state. Parameters use the same structure while -retaining constant-value lowering. +One module-level `ModuleVariablePlan` owns each declaring native variable and +its completed getter, setter, ownership, descriptor, array, and derived-object +mechanisms. Its owner path is the declaring native module and name, independent +of Python publication. A namespace-level `ModuleVariablePublicationPlan` +records only the Python names that point to that canonical plan. Re-exporting +module state therefore adds publication records without changing variable +identity or adding accessors, support procedures, initialization, allocation +state, or pointer state. Parameters use the same structure while retaining +constant-value lowering. `NativeEntrypointModulePlan.support_procedures` is the authoritative registry for externally linked generated helper callables that are not ordinary wrapped @@ -152,7 +154,7 @@ For each module, the planner first collects top-level and nested semantic classes into one depth-first, source-ordered tuple. That same collection feeds derived-type name indexing, backend-symbol allocation, and `_ClassPolicyCatalog`, so a nested class cannot reach projection without its -symbol being registered. It projects direct functions and variables, then uses +symbol being registered. It projects direct functions and canonical variables, then uses the catalogue to join each public class to its completed derived-type, surface, method, and overload policies. The catalogue is read-only: it maps existing owner paths to their semantic declarations without deciding policy again. @@ -165,9 +167,9 @@ plans and returns one editable `ModulePlan`. ### `models.py`: shared plans and three lowering views -`models.py` defines editable `StageRecord` plans. `ModulePlan` is the root; -each `NamespacePlan` groups the public functions, variables, derived types, -classes, and overloads for one Python path. A `FunctionPlan` owns call-wide +`models.py` defines editable `StageRecord` plans. `ModulePlan` is the root and +owns canonical module variables; each `NamespacePlan` groups public functions, +variable publications, derived types, classes, and overloads for one Python path. A `FunctionPlan` owns call-wide ordering, while its transfers, results, entrypoint parameters, projected call slots, optional adapter facets, and lifecycle actions carry the datatype-specific details. diff --git a/docs/user/reference/pyi-format.md b/docs/user/reference/pyi-format.md index 7b87fed8f..fd72f83d9 100644 --- a/docs/user/reference/pyi-format.md +++ b/docs/user/reference/pyi-format.md @@ -221,8 +221,11 @@ constant semantics in every namespace. A generic is a dispatch surface rather than one object and remains publishable only by its declaring namespace. PRIK writes the list into every generated contract, holding what the Fortran -source publishes: the module's own public declarations, and any imported name it -names in a `public` statement. Edit it freely. +source publishes: the module's own public declarations, explicitly public +imports, and other accessible imported names that are not dependencies of its +own declarations. For example, a type imported only to declare an argument +stays available as an import in the contract but is not published unless the +module names it in a `public` statement. Edit the list freely. | Edit | Effect | | --- | --- | diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index ad9a8d6a4..5eb259a5c 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -344,7 +344,10 @@ def binding_module(self, plan: ModulePlan) -> CModule: identity[1].casefold(): name for identity, name in class_python_names.items() } # Stage 3: select support and assemble generated functions in dependency order. - functions = tuple(function for namespace in plan.namespaces for function in self.visit(namespace)) + functions = ( + *(function for namespace in plan.namespaces for function in self.visit(namespace)), + *(function for variable in plan.variables for function in self.visit(variable)), + ) needs_native_support = self.requires_native_support(plan) needs_free = self._module_needs_allocator(plan) return CModule( @@ -657,10 +660,7 @@ def _scalar_result_expression(self, scalar, value_pointer: str, *, module: bool def _visit_NamespacePlan(self, plan: NamespacePlan) -> tuple[CFunction, ...]: """Return binding functions directly owned by one Python namespace.""" - return ( - *(self.visit(function) for function in plan.functions), - *(function for variable in plan.variables for function in self.visit(variable)), - ) + return tuple(self.visit(function) for function in plan.functions) def requires_native_support(self, plan: ModulePlan) -> bool: """Return whether module lowering consumes bundled native helpers.""" @@ -785,8 +785,7 @@ def _module_uses_memory_copy(self, plan: ModulePlan) -> bool: or self._module_uses_array_result_copy(plan) or any( variable.binding.getter_action is ModuleGetterAction.NATIVE_CONSTANT_ARRAY_VALUE - for namespace in plan.namespaces - for variable in namespace.variables + for variable in plan.variables ) or self._module_uses_derived_string_copy(plan) or self._module_uses_non_direct_derived_calls(plan) @@ -6024,10 +6023,9 @@ def _documented(functions: tuple[CFunction, ...], *doc: str) -> tuple[CFunction, def _visit_ModuleVariablePlan(self, plan: ModuleVariablePlan) -> tuple[CFunction, ...]: """Lower binding-owned getter and setter actions into C functions.""" - # The binding facet names the Python attribute and the C symbols it - # calls; the native Fortran variable belongs to the bridge facet and is - # deliberately not read here. - name = plan.binding.python_names[0] + # One helper serves every Python publication, so its documentation uses + # the stable declaring native name rather than an arbitrary alias. + name = plan.owner_path.rsplit(".", 1)[-1] return ( *self._documented( self._lower_module_getter(plan), @@ -6128,7 +6126,7 @@ def _lower_module_setter_character_value(self, plan: ModuleVariablePlan) -> tupl attribute assignment is an ``int`` slot, not a returned object. """ length = self._module_character_length(plan) - name = plan.binding.python_names[0] + name = plan.owner_path.rsplit(".", 1)[-1] return ( CFunction( self._module_setter_name(plan), @@ -6666,7 +6664,7 @@ def _module_setter_unpack_statement(self, plan, scalar_type) -> CExpressionState "value", ( f'PyErr_Format(PyExc_TypeError, "Expected an argument of type ' - f"{scalar_type.python_type_name} for module variable {plan.binding.python_names[0]}. " + f"{scalar_type.python_type_name} for module variable {plan.owner_path.rsplit('.', 1)[-1]}. " "Received \", Py_TYPE(value_obj)->tp_name)" ), "-1", @@ -14766,10 +14764,14 @@ def _method_table(self, module: ModulePlan, namespace: NamespacePlan) -> CMethod """Build method table from the supplied completed binding records; emitted nodes only project completed binding actions.""" return CMethodDefTable( f"{module.binding.owner_path}_{self._namespace_symbol(namespace)}_methods", - self._method_entries(namespace), + self._method_entries(module, namespace), ) - def _method_entries(self, namespace: NamespacePlan) -> tuple[CMethodDefEntry, ...]: + def _method_entries( + self, + module: ModulePlan, + namespace: NamespacePlan, + ) -> tuple[CMethodDefEntry, ...]: """Return the exact callable definitions installed in one namespace.""" return ( *( @@ -14792,7 +14794,7 @@ def _method_entries(self, namespace: NamespacePlan) -> tuple[CMethodDefEntry, .. for surface in namespace.classes if surface.constructor.kind is not ClassConstructorKind.ABSENT ), - *self._derived_private_method_entries(namespace), + *self._derived_private_method_entries(module, namespace), ) @staticmethod @@ -15240,14 +15242,18 @@ def _overload_default_case(self, overload: OverloadPlan) -> CCase: ), ) - def _derived_private_method_entries(self, namespace: NamespacePlan) -> tuple[CMethodDefEntry, ...]: + def _derived_private_method_entries( + self, + module: ModulePlan, + namespace: NamespacePlan, + ) -> tuple[CMethodDefEntry, ...]: """Expose private field callables used by generated Python properties.""" names = ( *self._direct_field_method_names(namespace), - *self._module_member_method_names(namespace), + *self._module_member_method_names(module, namespace), *self._allocatable_holder_method_names(namespace), *self._pointer_holder_method_names(namespace), - *self._module_proxy_guard_method_names(namespace), + *self._module_proxy_guard_method_names(module, namespace), ) return tuple(CMethodDefEntry(name, name, "METH_VARARGS", "") for name in names) @@ -15261,11 +15267,15 @@ def _direct_field_method_names(self, namespace: NamespacePlan) -> tuple[str, ... for action in self._field_method_actions(field) ) - def _module_member_method_names(self, namespace: NamespacePlan) -> tuple[str, ...]: + def _module_member_method_names( + self, + module: ModulePlan, + namespace: NamespacePlan, + ) -> tuple[str, ...]: """Return the binding-local module member method names derived from the supplied completed binding records; this helper preserves completed policy.""" return tuple( self._module_member_method_name(variable, member, action) - for variable in namespace.variables + for variable in self._support_variables(module, namespace) if variable.derived is not None and variable.derived.access is ModuleObjectAccessMechanism.MEMBER_PROXY for member in variable.derived.member_paths for action in self._field_method_actions(member.field) @@ -15309,16 +15319,20 @@ def _pointer_holder_method_names(self, namespace: NamespacePlan) -> tuple[str, . guards = tuple(self._pointer_holder_presence_method_name(derived.backend_symbol) for derived in holders) return (*fields, *guards) - def _module_proxy_guard_method_names(self, namespace: NamespacePlan) -> tuple[str, ...]: + def _module_proxy_guard_method_names( + self, + module: ModulePlan, + namespace: NamespacePlan, + ) -> tuple[str, ...]: """Return the binding-local module proxy guard method names derived from the supplied completed binding records; this helper preserves completed policy.""" presence = tuple( self._module_derived_presence_method_name(variable) - for variable in namespace.variables + for variable in self._support_variables(module, namespace) if self._nullable_derived_module_proxy(variable) ) native_ops = tuple( self._derived_origin_capsule_method_name(variable) - for variable in namespace.variables + for variable in self._support_variables(module, namespace) if variable.derived is not None ) return (*presence, *native_ops) @@ -15360,6 +15374,8 @@ def _module_init( CodeExpression(f"PyModule_Create(&{module_name}_{self._namespace_symbol(root_namespace)}_module)"), ), CExpressionStatement(CodeExpression("if (mod == NULL) return NULL")), + *self._module_initializer_nodes(plan), + *self._module_native_array_owner_nodes(plan, "mod"), *self._namespace_configuration_nodes( plan, root_namespace, @@ -15480,22 +15496,23 @@ def _namespace_configuration_nodes( return ( *property_nodes, *self._namespace_python_initializer_nodes( + module, namespace, object_name, ), - *self._module_native_array_owner_nodes(namespace, object_name), - *self._derived_module_owner_nodes(namespace, object_name), - *self._module_initializer_nodes(namespace), + *self._derived_module_owner_nodes(module, namespace, object_name), *self._module_constant_nodes(module, namespace, object_name), ) def _namespace_python_initializer_nodes( self, + module: ModulePlan, namespace: NamespacePlan, module_object: str, ) -> tuple[CDeclaration | CExpressionStatement | CIf, ...]: """Install exact overload dispatch plus generated opaque wrapper types.""" - has_proxy = any(variable.derived is not None for variable in namespace.variables) + variables = self._support_variables(module, namespace) + has_proxy = any(variable.derived is not None for variable in variables) if not namespace.derived_types and not has_proxy: return () allocatable_holders = self._namespace_binding_holder_types( @@ -15510,10 +15527,10 @@ def _namespace_python_initializer_nodes( allocatable_holder_identities=frozenset(derived.type_identity for derived in allocatable_holders), pointer_holder_identities=frozenset(derived.type_identity for derived in pointer_holders), nullable_module_proxy_owner_paths=frozenset( - variable.owner_path for variable in namespace.variables if self._nullable_derived_module_proxy(variable) + variable.owner_path for variable in variables if self._nullable_derived_module_proxy(variable) ), ) - source = PythonSurfaceEmitter(context).emit(namespace) + source = PythonSurfaceEmitter(context).emit(namespace, variables) literal = self._c_string_literal(source) result_name = f"{self._namespace_symbol(namespace)}_python_setup" dictionary = f"{self._namespace_symbol(namespace)}_python_dict" @@ -15537,12 +15554,12 @@ def _c_string_literal(value: str) -> str: def _module_native_array_owner_nodes( self, - namespace: NamespacePlan, - _module_object: str, + plan: ModulePlan, + module_object: str, ) -> tuple[CExpressionStatement, ...]: """Retain the root extension package for every borrowed native array.""" nodes = [] - for variable in namespace.variables: + for variable in plan.variables: if variable.binding.getter_action not in { ModuleGetterAction.BORROWED_ARRAY_VIEW, ModuleGetterAction.NATIVE_ARRAY_HANDLE, @@ -15551,32 +15568,36 @@ def _module_native_array_owner_nodes( owner = self._module_native_array_owner_name(variable) nodes.extend( ( - CExpressionStatement(CodeExpression("Py_INCREF(mod)")), - CExpressionStatement(CodeExpression(f"{owner} = mod")), + CExpressionStatement(CodeExpression(f"Py_INCREF({module_object})")), + CExpressionStatement(CodeExpression(f"{owner} = {module_object}")), ) ) return tuple(nodes) def _derived_module_owner_nodes( self, + module: ModulePlan, namespace: NamespacePlan, module_object: str, - ) -> tuple[CExpressionStatement, ...]: + ) -> tuple[CIf, ...]: """Retain one module reference for each live borrowed derived object.""" nodes = [] - for variable in namespace.variables: + for variable in self._support_variables(module, namespace): if variable.derived is None: continue owner = self._derived_module_owner_name(variable) - nodes.extend( - ( - CExpressionStatement(CodeExpression(f"Py_INCREF({module_object})")), - CExpressionStatement(CodeExpression(f"{owner} = {module_object}")), + nodes.append( + CIf( + CodeExpression(f"{owner} == NULL"), + body=( + CExpressionStatement(CodeExpression(f"Py_INCREF({module_object})")), + CExpressionStatement(CodeExpression(f"{owner} = {module_object}")), + ), ) ) return tuple(nodes) - def _module_initializer_nodes(self, namespace: NamespacePlan) -> tuple[CExpressionStatement, ...]: + def _module_initializer_nodes(self, plan: ModulePlan) -> tuple[CExpressionStatement, ...]: """Return import-time native assignments selected by completed policy.""" return tuple( CExpressionStatement( @@ -15585,7 +15606,7 @@ def _module_initializer_nodes(self, namespace: NamespacePlan) -> tuple[CExpressi f"{self._module_literal(variable, variable.binding.initializer)})" ) ) - for variable in namespace.variables + for variable in plan.variables if variable.binding.initializer is not None ) @@ -15606,11 +15627,7 @@ def _module_constant_nodes( ModuleGetterAction.NATIVE_CONSTANT_ARRAY_VALUE, }: continue - local_stem = ( - variable.symbol_name - if any(item.owner_path == variable.owner_path for item in namespace.variables) - else f"{namespace_symbol}_{variable.symbol_name}" - ) + local_stem = f"{namespace_symbol}_{variable.symbol_name}" for python_name in publication.python_names: value_name = f"constant_{local_stem}_value_{index}" object_name = f"constant_{local_stem}_object_{index}" @@ -15821,8 +15838,8 @@ def _functions(self, plan: ModulePlan) -> tuple[FunctionPlan, ...]: return tuple(function for namespace in plan.namespaces for function in namespace.functions) def _variables(self, plan: ModulePlan) -> tuple[ModuleVariablePlan, ...]: - """Return variables from the supplied completed binding records; this helper preserves the selected binding behavior.""" - return tuple(variable for namespace in plan.namespaces for variable in namespace.variables) + """Return the canonical module-variable registry in planner order.""" + return plan.variables def _variable_publications( self, @@ -15841,6 +15858,16 @@ def _variable_publications( resolved.append((variable, publication)) return tuple(resolved) + def _support_variables( + self, + plan: ModulePlan, + namespace: NamespacePlan, + ) -> tuple[ModuleVariablePlan, ...]: + """Read the planned namespace for private module-variable helpers.""" + return tuple( + variable for variable in plan.variables if variable.binding.support_namespace == namespace.python_path + ) + def _namespace(self, plan: ModulePlan, python_path: tuple[str, ...]) -> NamespacePlan: """Return the binding-local namespace derived from the supplied completed binding records; this helper preserves completed policy.""" for namespace in plan.namespaces: diff --git a/prik/codegen/c/python_surface.py b/prik/codegen/c/python_surface.py index 1eb281dcf..74e8c0115 100644 --- a/prik/codegen/c/python_surface.py +++ b/prik/codegen/c/python_surface.py @@ -53,11 +53,19 @@ class PythonSurfaceEmitter(ClassVisitor): def __init__(self, context: PythonSurfaceContext) -> None: self._context = context - def emit(self, namespace: NamespacePlan) -> str: + def emit( + self, + namespace: NamespacePlan, + variables: tuple[ModuleVariablePlan, ...], + ) -> str: """Return overloads, opaque classes, and typed member operation maps.""" - return self.visit(namespace) + return self.visit(namespace, variables) - def _visit_NamespacePlan(self, namespace: NamespacePlan) -> str: + def _visit_NamespacePlan( + self, + namespace: NamespacePlan, + variables: tuple[ModuleVariablePlan, ...], + ) -> str: """Render one planned namespace as executable Python source.""" surfaces = self._class_surfaces(namespace) class_names = self._class_names(namespace) @@ -75,7 +83,7 @@ def _visit_NamespacePlan(self, namespace: NamespacePlan) -> str: ), ] sections.extend(self._holder_ops_python_sources(namespace)) - sections.extend(self._module_proxy_ops_python_sources(namespace)) + sections.extend(self._module_proxy_ops_python_sources(variables)) return "\n\n".join(section for section in sections if section) @staticmethod @@ -107,12 +115,13 @@ def _holder_ops_python_sources(self, namespace: NamespacePlan) -> tuple[str, ... ), ) - def _module_proxy_ops_python_sources(self, namespace: NamespacePlan) -> tuple[str, ...]: + def _module_proxy_ops_python_sources( + self, + variables: tuple[ModuleVariablePlan, ...], + ) -> tuple[str, ...]: """Render persistent module-derived operation maps in declaration order.""" return tuple( - self._module_proxy_ops_python_source(variable) - for variable in namespace.variables - if variable.derived is not None + self._module_proxy_ops_python_source(variable) for variable in variables if variable.derived is not None ) def _derived_type_python_source( @@ -613,4 +622,4 @@ def _module_proxy_ops_literal( example_context = PythonSurfaceContext(frozenset(), frozenset(), frozenset()) print("Rendered Python facade:") - print(PythonSurfaceEmitter(example_context).emit(example_namespace)) + print(PythonSurfaceEmitter(example_context).emit(example_namespace, ())) diff --git a/prik/codegen/docstrings.py b/prik/codegen/docstrings.py index 4aa5c11fc..0608c774b 100644 --- a/prik/codegen/docstrings.py +++ b/prik/codegen/docstrings.py @@ -99,9 +99,7 @@ def render(self, plan: ModulePlan) -> ModulePlan: for surface in namespace.classes if surface.python_names } - self._module_variables_by_owner = { - variable.owner_path: variable for namespace in plan.namespaces for variable in namespace.variables - } + self._module_variables_by_owner = {variable.owner_path: variable for variable in plan.variables} # A publication can sort before the namespace that owns its canonical # variable plan. Render every canonical variable first so namespace # summaries only read completed documentation from that owner. @@ -118,8 +116,6 @@ def _render_namespace(self, module_name: str, namespace: NamespacePlan) -> None: for derived_type in namespace.derived_types: for field in derived_type.fields: self._render_field(field) - for variable in namespace.variables: - self._render_module_variable(variable) for overload in namespace.overloads: self._render_overload(overload) @@ -494,7 +490,7 @@ def module_variable(self, variable: ModuleVariablePlan) -> str: documentation. Getter, setter, array-handle, and derived-object text comes directly from the completed variable plan. """ - name = variable.binding.python_names[0] + name = variable.owner_path.rsplit(".", 1)[-1] nullable = variable.binding.getter_action is ModuleGetterAction.NULLABLE_SNAPSHOT lines = [f"{name} : {self._type(variable, nullable=nullable, signature=False)}"] lines.extend(self._array_lines(variable.array)) @@ -1175,7 +1171,7 @@ def _module_variable_summary_lines( _name, separator, type_name = first.partition(" : ") if not separator: return (first,) - names = variable.binding.python_names if python_names is None else python_names + names = (variable.owner_path.rsplit(".", 1)[-1],) if python_names is None else python_names return tuple(line for name in names for line in (f"{name} : {type_name}", *details)) def _keyword_field_signature( diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index e19c7a825..05fbae19e 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -271,6 +271,36 @@ def _adapter_slots(function: FunctionPlan) -> tuple[NativeEntrypointProjectedSlo def _visit_ModulePlan(self, plan: ModulePlan) -> FortranModule: """Build one complete bridge module from one validated module plan.""" + self._prepare_module_context(plan) + procedures = self._module_procedures(plan) + # Assemble imports, declarations, and procedures from plan projections. + return FortranModule( + name=f"bind_c_{plan.entrypoint.owner_path}_wrapper", + uses=( + FortranUse("iso_c_binding", self._iso_c_symbols(plan)), + *self._native_module_uses(plan), + ), + type_definitions=( + *self._derived_holder_definitions(plan), + *self._native_array_owner_definitions(plan), + ), + interfaces=( + *self._derived_call_interfaces(plan), + *self._prototype_interfaces(plan), + *self._external_interfaces(plan), + *self._module_descriptor_callback_interfaces(plan), + *self._derived_array_callback_interfaces(plan), + *self._native_array_owner_callback_interfaces(plan), + *self._module_array_capture_interfaces(plan), + *self._allocator_interfaces(plan), + ), + declarations=self._prototype_entity_declarations(plan), + procedures=self._apply_generated_support_procedure_entrypoints(procedures), + standalone_procedures=self._callback_standalone_adapter_procedures(plan), + ) + + def _prepare_module_context(self, plan: ModulePlan) -> None: + """Cache validated module-wide facts consumed by bridge emitters.""" self._generated_support_procedure_entrypoints = { (procedure.owner_path, procedure.role): procedure for procedure in plan.entrypoint.support_procedures } @@ -290,73 +320,59 @@ def _visit_ModulePlan(self, plan: ModulePlan) -> FortranModule: plan.bridge.allocatable_holder_field_type_owner_paths ) self._bridge_pointer_holder_field_owner_paths = frozenset(plan.bridge.pointer_holder_field_type_owner_paths) + + def _module_procedures(self, plan: ModulePlan) -> tuple[FortranFunction, ...]: + """Return bridge procedures in their established emission order.""" # Scoped origins are module-wide facts needed by derived-call lowering. scoped_origin_type_identities = self._scoped_origin_type_identities(plan) - procedures = ( + return ( *( procedure for namespace in plan.namespaces for procedure in self.visit(namespace, scoped_origin_type_identities) ), + *(procedure for variable in plan.variables for procedure in self.visit(variable)), # Typed derived-field access remains separate from class orchestration. *self._derived_field_procedures(plan), # Native-aware opaque-owner destruction is Phase 8 substrate, not class orchestration. *self._class_constructor_procedures(plan), + *self._derived_lifecycle_procedures(plan), + *( + procedure + for variable in self._derived_origin_variables(plan) + for procedure in self._derived_origin_procedures(variable) + ), + ) + + def _derived_lifecycle_procedures(self, plan: ModulePlan) -> tuple[FortranFunction, ...]: + """Return planned destruction and presence helpers for derived storage.""" + derived_types = self._derived_types(plan) + return ( *( self._derived_destroy_procedure(derived) - for derived in self._derived_types(plan) + for derived in derived_types if self._has_generated_support_procedure_entrypoint(derived.owner_path, "derived:destroy") ), *( self._allocatable_holder_destroy_procedure(derived) - for derived in self._derived_types(plan) + for derived in derived_types if self._has_generated_support_procedure_entrypoint(derived.owner_path, "holder:allocatable:destroy") ), *( self._allocatable_holder_presence_procedure(derived) - for derived in self._derived_types(plan) + for derived in derived_types if self._has_generated_support_procedure_entrypoint(derived.owner_path, "holder:allocatable:present") ), *( self._pointer_holder_destroy_procedure(derived) - for derived in self._derived_types(plan) + for derived in derived_types if self._has_generated_support_procedure_entrypoint(derived.owner_path, "holder:pointer:destroy") ), *( self._pointer_holder_presence_procedure(derived) - for derived in self._derived_types(plan) + for derived in derived_types if self._has_generated_support_procedure_entrypoint(derived.owner_path, "holder:pointer:present") ), - *( - procedure - for variable in self._derived_origin_variables(plan) - for procedure in self._derived_origin_procedures(variable) - ), - ) - # Assemble imports, declarations, and procedures from plan projections. - return FortranModule( - name=f"bind_c_{plan.entrypoint.owner_path}_wrapper", - uses=( - FortranUse("iso_c_binding", self._iso_c_symbols(plan)), - *self._native_module_uses(plan), - ), - type_definitions=( - *self._derived_holder_definitions(plan), - *self._native_array_owner_definitions(plan), - ), - interfaces=( - *self._derived_call_interfaces(plan), - *self._prototype_interfaces(plan), - *self._external_interfaces(plan), - *self._module_descriptor_callback_interfaces(plan), - *self._derived_array_callback_interfaces(plan), - *self._native_array_owner_callback_interfaces(plan), - *self._module_array_capture_interfaces(plan), - *self._allocator_interfaces(plan), - ), - declarations=self._prototype_entity_declarations(plan), - procedures=self._apply_generated_support_procedure_entrypoints(procedures), - standalone_procedures=self._callback_standalone_adapter_procedures(plan), ) def _generated_support_procedure_entrypoint( @@ -597,7 +613,6 @@ def _visit_NamespacePlan( for function in plan.functions for procedure in self._default_native_array_argument_operations(function) ), - *(procedure for variable in plan.variables for procedure in self.visit(variable)), ) def _visit_FunctionPlan( @@ -9477,8 +9492,8 @@ def _functions(self, plan: ModulePlan) -> tuple[FunctionPlan, ...]: return tuple(function for namespace in plan.namespaces for function in namespace.functions) def _variables(self, plan: ModulePlan) -> tuple[ModuleVariablePlan, ...]: - """Flatten namespaces into module-variable plans while preserving module and namespace order.""" - return tuple(variable for namespace in plan.namespaces for variable in namespace.variables) + """Return the canonical module-variable registry in planner order.""" + return plan.variables def _iso_symbol(self, semantic_type_name: str) -> str: """Return the iso_c_binding symbol required by one semantic primitive type.""" diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index 63f98bb8f..76bdbe1e3 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -326,14 +326,14 @@ def _plan_diagnostics(self, plan: ModulePlan) -> tuple[WrapperPlanDiagnostic, .. diagnostics.extend(self._generated_support_procedure_entrypoint_diagnostics(plan)) diagnostics.extend(self._namespace_tree_diagnostics(plan)) diagnostics.extend(self._module_variable_publication_diagnostics(plan)) + for variable in plan.variables: + diagnostics.extend(self._module_variable_diagnostics(variable)) - # Validate every typed member against the shared records in its namespace. + # Validate every namespace-owned member against its shared records. for namespace in plan.namespaces: diagnostics.extend(self._namespace_diagnostics(namespace)) for function in namespace.functions: diagnostics.extend(self._function_diagnostics(function)) - for variable in namespace.variables: - diagnostics.extend(self._module_variable_diagnostics(variable)) for class_surface in namespace.classes: diagnostics.extend(self._class_surface_diagnostics(namespace, class_surface)) functions = {id(function) for function in namespace.functions} @@ -405,7 +405,7 @@ def _generated_support_procedure_entrypoint_diagnostics( for operation in operations: diagnostics.extend(self._generated_support_procedure_diagnostics(operation)) try: - expected_projection = build_generated_support_procedure_projection(plan.namespaces) + expected_projection = build_generated_support_procedure_projection(plan.namespaces, plan.variables) except ValueError as error: diagnostics.append(self._diagnostic(plan.owner_path, "invalid-auxiliary-entrypoint-inventory", str(error))) return tuple(diagnostics) @@ -522,8 +522,14 @@ def _required_header_diagnostics(self, plan: ModulePlan) -> tuple[WrapperPlanDia """Require module headers to equal the completed handle-plan union.""" handles = tuple( handle - for namespace in plan.namespaces - for handle in self._namespace_native_array_handles(namespace) + for handle in ( + *(variable.native_array_handle for variable in plan.variables), + *( + handle + for namespace in plan.namespaces + for handle in self._namespace_native_array_handles(namespace) + ), + ) if handle is not None ) expected_headers = list(self._native_array_required_headers(handles)) @@ -583,7 +589,6 @@ def _namespace_native_array_handles( return ( *(argument.native_array_handle for function in namespace.functions for argument in function.arguments), *(result.native_array_handle for function in namespace.functions for result in function.results), - *(variable.native_array_handle for variable in namespace.variables), *(field.native_array_handle for derived in namespace.derived_types for field in derived.fields), ) @@ -1042,14 +1047,6 @@ def _export_owner_diagnostics(self, plan: NamespacePlan) -> tuple[WrapperPlanDia diagnostics.append( self._diagnostic(function.owner_path, "inconsistent-function-export-owner", expected_owner) ) - for variable in plan.variables: - if not variable.binding.python_names: - continue - expected_owner = f"{plan.owner_path}.{variable.binding.python_names[0]}" - if variable.owner_path != expected_owner: - diagnostics.append( - self._diagnostic(variable.owner_path, "inconsistent-variable-export-owner", expected_owner) - ) for overload in plan.overloads: expected_owner = f"{plan.owner_path}.{overload.python_name}" if overload.owner_path != expected_owner: @@ -1063,8 +1060,18 @@ def _module_variable_publication_diagnostics( plan: ModulePlan, ) -> tuple[WrapperPlanDiagnostic, ...]: """Validate that every publication references one canonical variable plan.""" - owners = {variable.owner_path for namespace in plan.namespaces for variable in namespace.variables} + owners = {variable.owner_path for variable in plan.variables} diagnostics = [] + namespace_paths = {namespace.python_path for namespace in plan.namespaces} + diagnostics.extend( + self._diagnostic( + variable.owner_path, + "missing-module-variable-support-namespace", + variable.binding.support_namespace, + ) + for variable in plan.variables + if variable.binding.support_namespace not in namespace_paths + ) for namespace in plan.namespaces: for publication in namespace.variable_publications: if publication.variable_owner_path not in owners: @@ -1090,11 +1097,18 @@ def _generated_symbol_diagnostics(self, plan: ModulePlan) -> tuple[WrapperPlanDi owners_by_symbol: dict[str, list[str]] = {} diagnostics = list(self._namespace_symbol_diagnostics(plan)) for namespace in plan.namespaces: - for item in (*namespace.functions, *namespace.variables): + for item in namespace.functions: if not item.symbol_name or not item.symbol_name.isidentifier(): diagnostics.append(self._diagnostic(item.owner_path, "invalid-generated-symbol", item.symbol_name)) continue owners_by_symbol.setdefault(item.symbol_name.casefold(), []).append(item.owner_path) + for variable in plan.variables: + if not variable.symbol_name or not variable.symbol_name.isidentifier(): + diagnostics.append( + self._diagnostic(variable.owner_path, "invalid-generated-symbol", variable.symbol_name) + ) + continue + owners_by_symbol.setdefault(variable.symbol_name.casefold(), []).append(variable.owner_path) diagnostics.extend( self._diagnostic(plan.owner_path, "duplicate-generated-symbol", f"{symbol}:{','.join(owners)}") for symbol, owners in owners_by_symbol.items() @@ -1120,8 +1134,6 @@ def _module_variable_diagnostics( ) -> tuple[WrapperPlanDiagnostic, ...]: """Return getter, setter, and initialization consistency diagnostics.""" diagnostics = [] - if not plan.binding.python_names: - diagnostics.append(self._diagnostic(plan.owner_path, "missing-module-python-name", plan.owner_path)) diagnostics.extend(self._module_variable_entrypoint_diagnostics(plan)) diagnostics.extend(self._module_getter_diagnostics(plan)) if plan.binding.getter_action is ModuleGetterAction.DERIVED_OBJECT: diff --git a/prik/planning/entrypoints.py b/prik/planning/entrypoints.py index bff7211bf..f86889bf7 100644 --- a/prik/planning/entrypoints.py +++ b/prik/planning/entrypoints.py @@ -89,9 +89,10 @@ class GeneratedSupportProcedureProjection: def build_generated_support_procedure_projection( namespaces: tuple[NamespacePlan, ...], + variables: tuple[ModuleVariablePlan, ...], ) -> GeneratedSupportProcedureProjection: """Return external and backend-local support membership in stable order.""" - builder = _GeneratedSupportProcedureEntrypointBuilder(namespaces) + builder = _GeneratedSupportProcedureEntrypointBuilder(namespaces, variables) projection = builder.build() procedures = projection.support_procedures keys = [procedure.key for procedure in procedures] @@ -112,7 +113,7 @@ def build_callback_support_procedure_entrypoint( result, ) -> GeneratedSupportProcedureEntrypointPlan: """Project the binding trampoline once while its callback site is planned.""" - builder = _GeneratedSupportProcedureEntrypointBuilder(()) + builder = _GeneratedSupportProcedureEntrypointBuilder((), ()) parameters = tuple( parameter for transfer in arguments for parameter in builder._callback_transfer_parameters(transfer) ) @@ -129,10 +130,14 @@ def build_callback_support_procedure_entrypoint( class _GeneratedSupportProcedureEntrypointBuilder: """Project operation existence, symbols, and ABI signatures from completed plans.""" - def __init__(self, namespaces: tuple[NamespacePlan, ...]) -> None: + def __init__( + self, + namespaces: tuple[NamespacePlan, ...], + variables: tuple[ModuleVariablePlan, ...], + ) -> 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.variables = variables # One native type may be exported through several Python namespaces. # Its support procedures belong to the native type, not each export. derived_by_identity = {} diff --git a/prik/planning/models.py b/prik/planning/models.py index 0c4af07bc..945ebae43 100644 --- a/prik/planning/models.py +++ b/prik/planning/models.py @@ -741,13 +741,9 @@ class BridgeModulePlan(StageRecord): @dataclass class BindingModuleVariablePlan(StageRecord): - """Describe Python module-attribute access and initialization for one value. + """Describe Python access and initialization for one native module value.""" - ``python_names`` retains every public spelling. The binding consumes the - completed getter and setter actions plus the selected initializer/value. - """ - - python_names: tuple[str, ...] + support_namespace: tuple[str, ...] getter_action: ModuleGetterAction setter_action: SetterAction initializer: Any @@ -783,7 +779,8 @@ class ModuleVariablePlan(StageRecord): """Join binding, entrypoint, and bridge views of one module-state value. Optional array, native-handle, and derived-object facets are attached only - when policy selected them. Namespace plans own these records for emission. + when policy selected them. ``ModulePlan`` owns these records by declaring + native identity; namespace plans contain publications only. """ owner_path: str @@ -1418,14 +1415,14 @@ class NamespacePlan(StageRecord): """Represent one Python namespace and its directly exported wrapper owners. ``python_path`` identifies the root or child module path; contained tuples - preserve planner order for functions, variables, types, classes, and - overloads. ``ModulePlan`` groups these namespaces into one generation unit. + preserve planner order for functions, variable publications, types, + classes, and overloads. ``ModulePlan`` groups these namespaces into one + generation unit. """ owner_path: str python_path: tuple[str, ...] functions: tuple[FunctionPlan, ...] = () - variables: tuple[ModuleVariablePlan, ...] = () variable_publications: tuple[ModuleVariablePublicationPlan, ...] = () derived_types: tuple[DerivedTypePlan, ...] = () classes: tuple[ClassSurfacePlan, ...] = () @@ -1448,6 +1445,7 @@ class ModulePlan(StageRecord): binding: BindingModulePlan entrypoint: NativeEntrypointModulePlan bridge: BridgeModulePlan | None + variables: tuple[ModuleVariablePlan, ...] namespaces: tuple[NamespacePlan, ...] native_generated_code_groups: tuple[NativeGeneratedCodeGroupPlan, ...] = () required_headers: tuple[str, ...] = () @@ -1506,6 +1504,7 @@ class WrapperPlanDiagnostic(StageRecord): binding=BindingModulePlan(owner_path="demo"), entrypoint=NativeEntrypointModulePlan(owner_path="demo"), bridge=BridgeModulePlan(owner_path="demo"), + variables=(), namespaces=(NamespacePlan(owner_path="demo", python_path=(), functions=(function,)),), ) diff --git a/prik/planning/planner.py b/prik/planning/planner.py index 5ac988284..1ebd93ed0 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -380,7 +380,7 @@ def _visit_SemanticModule(self, module: models.SemanticModule) -> ModulePlan: if not any( ( *functions.values(), - *variables.values(), + variables, *variable_publications.values(), *derived_types.values(), *classes.values(), @@ -405,7 +405,7 @@ def _visit_SemanticModule(self, module: models.SemanticModule) -> ModulePlan: overloads, aliases, ) - support_projection = build_generated_support_procedure_projection(namespaces) + support_projection = build_generated_support_procedure_projection(namespaces, variables) support_procedures = support_projection.support_procedures generated_code_groups = self._native_generated_code_groups( module.name, @@ -436,9 +436,10 @@ def _visit_SemanticModule(self, module: models.SemanticModule) -> ModulePlan: if generated_code_groups else None ), + variables=variables, namespaces=namespaces, native_generated_code_groups=generated_code_groups, - required_headers=self._required_headers(namespaces), + required_headers=self._required_headers(namespaces, variables), ) @staticmethod @@ -494,14 +495,14 @@ def _namespace_member_plans( ) -> tuple[dict, dict, dict, dict, dict, dict]: """Build namespace-owned plan maps from one shared class-policy catalog. - Direct functions and variables are projected first. The local catalog + Direct functions and canonical variables are projected first. The local catalog then organizes each public class once so derived-type and Python-class projections consume the same semantic declaration, completed policies, and callable owner-path maps. """ # Project ordinary module members independently from class-owned surfaces. functions = self._functions_by_namespace(module) - variables, variable_publications = self._variables_by_namespace(module) + variables, variable_publications = self._module_variables_and_publications(module) return ( functions, @@ -529,7 +530,7 @@ def _namespace_plans( self, module_name: str, functions: dict, - variables: dict, + variables: tuple[ModuleVariablePlan, ...], variable_publications: dict, derived_types: dict, classes: dict, @@ -539,14 +540,13 @@ def _namespace_plans( """Freeze linked namespace members in dependency-safe path order.""" self._complete_generated_symbols(functions, variables) namespace_paths = self._namespace_paths( - (*functions, *variables, *variable_publications, *derived_types, *classes, *overloads, *aliases) + (*functions, *variable_publications, *derived_types, *classes, *overloads, *aliases) ) - return tuple( + namespaces = tuple( self._namespace_plan( module_name, path, tuple(functions[path]), - tuple(variables[path]), tuple(variable_publications[path]), tuple(derived_types[path]), tuple(classes[path]), @@ -555,6 +555,33 @@ def _namespace_plans( ) for path in namespace_paths ) + self._complete_variable_support_namespaces(module_name, variables, namespaces) + return namespaces + + @staticmethod + def _complete_variable_support_namespaces( + module_name: str, + variables: tuple[ModuleVariablePlan, ...], + namespaces: tuple[NamespacePlan, ...], + ) -> None: + """Place private variable helpers without changing canonical ownership.""" + type_paths: dict[tuple[str, str], list[tuple[str, ...]]] = defaultdict(list) + for namespace in namespaces: + for derived in namespace.derived_types: + type_paths[derived.type_identity].append(namespace.python_path) + for variable in variables: + if variable.derived is None: + variable.binding.support_namespace = () + continue + identity = variable.derived.handoff.type_identity + candidates = type_paths.get(identity, [()]) + native_scope = identity[0] + native_path = ( + () + if native_scope.casefold() == module_name.casefold() + else tuple(part.casefold() for part in native_scope.split(".") if part) + ) + variable.binding.support_namespace = native_path if native_path in candidates else candidates[0] def _aliases_by_namespace(self, module: models.SemanticModule) -> dict[tuple[str, ...], list[NamespaceAliasPlan]]: """Group each published re-export under the namespace that publishes it. @@ -623,7 +650,6 @@ def _namespace_plan( module_name: str, path: tuple[str, ...], functions: tuple[FunctionPlan, ...], - variables: tuple[ModuleVariablePlan, ...], variable_publications: tuple[ModuleVariablePublicationPlan, ...], derived_types: tuple[DerivedTypePlan, ...], classes: tuple[ClassSurfacePlan, ...], @@ -635,7 +661,6 @@ def _namespace_plan( owner_path=self._namespace_owner_path(module_name, path), python_path=path, functions=functions, - variables=variables, variable_publications=variable_publications, derived_types=derived_types, classes=classes, @@ -1137,15 +1162,15 @@ def _module_function_policy(function: models.SemanticFunction) -> FunctionWrappe return None return completed_function_wrapper_policy(function) - def _variables_by_namespace( + def _module_variables_and_publications( self, module: models.SemanticModule, ) -> tuple[ - dict[tuple[str, ...], list[ModuleVariablePlan]], + tuple[ModuleVariablePlan, ...], dict[tuple[str, ...], list[ModuleVariablePublicationPlan]], ]: - """Plan each native variable once and group its Python publications.""" - variables = defaultdict(list) + """Build the canonical native-variable registry and namespace publications.""" + variables = [] publications = defaultdict(list) for variable in module.variables: if variable.visibility != "public": @@ -1154,15 +1179,8 @@ def _variables_by_namespace( exports_by_namespace = defaultdict(list) for export in policy.python_exports: exports_by_namespace[export.namespace].append(export.name) - declaring_namespace = self._canonical_variable_namespace(policy, module.name, set(exports_by_namespace)) - declaring_names = tuple(exports_by_namespace.get(declaring_namespace, ())) or (policy.name,) - plan = self._module_variable_plan( - policy, - declaring_namespace, - declaring_names, - module.name, - ) - variables[declaring_namespace].append(plan) + plan = self._module_variable_plan(policy) + variables.append(plan) for namespace, python_names in exports_by_namespace.items(): publications[namespace].append( ModuleVariablePublicationPlan( @@ -1170,39 +1188,16 @@ def _variables_by_namespace( python_names=tuple(python_names), ) ) - return variables, publications - - @staticmethod - def _canonical_variable_namespace( - policy, - module_name: str, - exported: set[tuple[str, ...]], - ) -> tuple[str, ...]: - """Return the namespace the one native variable plan is owned by. - - Ownership follows the namespace declaring the variable wherever that - namespace publishes it. A contract may publish a variable only through - a facade, though, and native ownership must not put the declaring - namespace into Python merely to hold the plan, so ownership moves to a - namespace that is published. The choice is the least path so one plan - owns the variable whichever order namespaces are walked in. - """ - native_namespace = tuple(part.casefold() for part in str(policy.native_module).split(".") if part) - if native_namespace in exported: - return native_namespace - if () in exported and str(policy.native_module).casefold() == module_name.casefold(): - return () - if exported: - return min(exported) - return native_namespace + return tuple(variables), publications def _complete_generated_symbols( self, functions: dict[tuple[str, ...], list[FunctionPlan]], - variables: dict[tuple[str, ...], list[ModuleVariablePlan]], + variables: tuple[ModuleVariablePlan, ...], ) -> None: - """Keep unique symbols short and qualify only colliding local names.""" - entries = (*self._planned_items(functions), *self._planned_items(variables)) + """Keep unique symbols short and qualify from stable native identity.""" + variable_entries = tuple((self._variable_native_namespace(item), item) for item in variables) + entries = (*self._planned_items(functions), *variable_entries) counts = Counter(item.symbol_name.casefold() for _namespace, item in entries) for namespace, item in entries: if counts[item.symbol_name.casefold()] > 1: @@ -1245,24 +1240,22 @@ def _complete_entrypoint_symbols( def _qualify_variable_bridge_collisions( self, functions: dict[tuple[str, ...], list[FunctionPlan]], - variables: dict[tuple[str, ...], list[ModuleVariablePlan]], - ) -> None: - """Qualify a variable helper when its get/set spelling collides with a function.""" - for namespace, namespace_variables in variables.items(): - function_symbols = {function.symbol_name for function in functions[namespace]} - self._qualify_namespace_variable_helpers(namespace, namespace_variables, function_symbols) - - def _qualify_namespace_variable_helpers( - self, - namespace: tuple[str, ...], - variables: list[ModuleVariablePlan], - function_symbols: set[str], + variables: tuple[ModuleVariablePlan, ...], ) -> None: - """Resolve get/set helper collisions inside one Python namespace.""" + """Qualify a native variable helper when it collides with any function.""" + function_symbols = {function.symbol_name for items in functions.values() for function in items} for variable in variables: helper_symbols = {f"get_{variable.symbol_name}", f"set_{variable.symbol_name}"} if function_symbols & helper_symbols: - variable.symbol_name = self._symbol_name(namespace, variable.symbol_name) + variable.symbol_name = self._symbol_name( + self._variable_native_namespace(variable), + variable.symbol_name, + ) + + @staticmethod + def _variable_native_namespace(variable: ModuleVariablePlan) -> tuple[str, ...]: + """Return the declaring native path used only for generated-name qualification.""" + return tuple(part.casefold() for part in variable.owner_path.rsplit(".", 1)[0].split(".") if part) def _planned_items(self, grouped: dict[tuple[str, ...], list]) -> tuple[tuple[tuple[str, ...], object], ...]: """Flatten namespace groups while retaining each item's namespace.""" @@ -1271,23 +1264,18 @@ def _planned_items(self, grouped: dict[tuple[str, ...], list]) -> tuple[tuple[tu def _module_variable_plan( self, policy: ModuleVariablePolicy, - namespace: tuple[str, ...], - python_names: tuple[str, ...], - module_name: str, ) -> ModuleVariablePlan: """Project one completed module-variable policy into its shared plan record. - ``policy`` supplies all accessor, setter, descriptor, and derived - object decisions. ``namespace`` and ``python_names`` select the - exported owner path and binding aliases. The result shares array and - derived-field projections with the rest of the module; no accessor or - ownership policy is selected here. + ``policy`` supplies the declaring native identity plus all accessor, + setter, descriptor, and derived-object decisions. Publications are + projected separately and cannot change this record's owner path. """ # Roles are present only where the completed accessor policy requires them. getter_role = self._module_getter_role(policy) setter_role = f"{policy.owner_path}:setter" if policy.setter_action is SetterAction.WRITE_THROUGH else None return ModuleVariablePlan( - owner_path=self._export_owner_path(module_name, namespace, python_names[0]), + owner_path=policy.owner_path, symbol_name=policy.native_name.casefold(), semantic_type_name=policy.semantic_type_name, datatype_family=self._transfer_datatype_family( @@ -1295,7 +1283,7 @@ def _module_variable_plan( policy.derived.handoff if policy.derived is not None else None, ), binding=BindingModuleVariablePlan( - python_names=python_names, + support_namespace=(), getter_action=policy.getter_action, setter_action=policy.setter_action, initializer=policy.initializer, @@ -2716,12 +2704,18 @@ def _declaration_callable_roles( """Return bridge-resolved declaration-callable symbol roles.""" return tuple(item.symbolic_role for item in declaration_callables) - def _required_headers(self, namespaces: tuple[NamespacePlan, ...]) -> tuple[str, ...]: + def _required_headers( + self, + namespaces: tuple[NamespacePlan, ...], + variables: tuple[ModuleVariablePlan, ...], + ) -> tuple[str, ...]: """Return the union of headers selected by completed handle plans.""" handles = tuple( handle - for namespace in namespaces - for handle in self._namespace_native_array_handles(namespace) + for handle in ( + *(item.native_array_handle for item in variables), + *(handle for namespace in namespaces for handle in self._namespace_native_array_handles(namespace)), + ) if handle is not None ) headers = list(self._native_array_headers(handles)) @@ -2779,10 +2773,9 @@ def _namespace_native_array_handles( self, namespace: NamespacePlan, ) -> tuple[NativeArrayHandlePlan | None, ...]: - """Return argument, result, and module handle plans for one namespace.""" + """Return argument, result, and derived-field handles for one namespace.""" return ( *(handle for function in namespace.functions for handle in self._function_native_array_handles(function)), - *(variable.native_array_handle for variable in namespace.variables), *self._derived_field_native_array_handles(namespace), ) diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index 36c011946..387b9f418 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -200,10 +200,12 @@ def _public_owner_key(owner: object) -> object: def published_name(published: dict[str, str] | None, source: object) -> str | None: """Return the spelling a contract published one source name under. - A contract records the name exactly as its source spells it, so two + A contract records each name exactly as its source spells it, so two declarations a case-sensitive language keeps apart keep separate entries. - A case-insensitive source may still ask for either spelling, which the - fallback answers once no exact entry does. + A case-insensitive source may ask under any spelling, which is answered + only when one entry can mean it: where several fold together the request + names no single declaration, and guessing one would depend on the order + they happened to be recorded in. """ if not published: return None @@ -212,7 +214,8 @@ def published_name(published: dict[str, str] | None, source: object) -> str | No if exact is not None: return exact folded = wanted.casefold() - return next((value for key, value in published.items() if key.casefold() == folded), None) + matches = [value for key, value in published.items() if key.casefold() == folded] + return matches[0] if len(matches) == 1 else None # Publication of these kinds has no runtime form yet, so a generated contract diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 70058436d..e5104b178 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -1533,19 +1533,131 @@ def procedures_to_semantic_module( ), ) + @staticmethod + def _effective_accessibility(module: FortranModule): + """Return whether one name is public in this module, by Fortran's rules. + + Accessibility is settled by precedence: an access statement naming the + entity decides it, otherwise the module's bare ``public``/``private`` + default does, and the default is itself ``public``. A use-associated + entity is covered by those same rules, so an ordinary default-public + module publishes what it imports without naming it anywhere. + """ + default_public = str(getattr(module, "default_visibility", "public")).casefold() != "private" + explicit_public = {str(name).casefold() for name in getattr(module, "public_symbols", ())} + explicit_private = {str(name).casefold() for name in getattr(module, "private_symbols", ())} + + def is_public(name: object) -> bool: + folded = str(name).casefold() + if folded in explicit_private: + return False + if folded in explicit_public: + return True + return default_public + + return is_public + + @classmethod + def _module_declaration_dependencies(cls, module: FortranModule) -> set[str]: + """Return imported names used to express this module's declarations. + + The parser models retain declaration expressions but not executable + statements here, so intersecting their identifiers with names visible + through ``use`` distinguishes a dependency from an otherwise implicit + default-public re-export. An explicit ``public`` statement remains the + module's authoritative request to publish the name. + """ + + declaration_text: list[str] = [] + + def add_variable(variable: FortranVariable | None) -> None: + if variable is None: + return + declaration_text.extend( + str(value) + for value in ( + variable.kind, + variable.target_kind_expression, + variable.symbolic_value, + variable.value, + *variable.shape, + *variable.lbound, + *variable.ubound, + ) + if value is not None + ) + + def add_procedure(procedure: FortranProcedureSignature) -> None: + for argument in procedure.arguments: + add_variable(argument) + add_variable(procedure.result) + for variable in procedure.variables.values(): + add_variable(variable) + + for variable in module.variables: + add_variable(variable) + for procedure in module.procedures: + add_procedure(procedure) + for derived in module.derived_types: + if derived.extends is not None: + declaration_text.append(str(getattr(derived.extends, "name", derived.extends))) + for field in derived.fields: + add_variable(field) + for binding in derived.procedure_bindings: + interface_name = binding.get("interface") + if interface_name: + declaration_text.append(str(interface_name)) + for interface in module.interfaces: + for procedure in interface.procedures: + add_procedure(procedure) + + return { + identifier.casefold() for text in declaration_text for identifier in re.findall(r"\b[A-Za-z_]\w*\b", text) + } + @classmethod + def _module_public_names( + cls, + module: FortranModule, + index: dict[str, FortranModule], + seen: frozenset[str] = frozenset(), + ) -> set[str]: + """Return the names one module offers to a plain ``use`` of it.""" + key = module.name.casefold() + if key in seen: + return set() + seen = seen | {key} + is_public = cls._effective_accessibility(module) + dependencies = cls._module_declaration_dependencies(module) + explicit_public = {str(name).casefold() for name in module.public_symbols} + offered = { + *(procedure.name for procedure in module.procedures), + *(derived.name for derived in module.derived_types), + *(variable.name for variable in getattr(module, "variables", ())), + *(mapping.local_name for mappings in module.uses.values() for mapping in mappings), + } + offered.update( + name + for module_name, mappings in module.uses.items() + if not mappings and module_name.casefold() in index + for name in cls._module_public_names(index[module_name.casefold()], index, seen) + ) + return { + str(name).casefold() + for name in offered + if is_public(name) and (str(name).casefold() not in dependencies or str(name).casefold() in explicit_public) + } + def _module_reexports( cls, module: FortranModule, module_index: dict[str, FortranModule] | None = None, ) -> list[SemanticReexport]: - """Return the imported names this module explicitly publishes. + """Return the imported names this module 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, and a - ``use`` that publishes nothing explicitly re-exports nothing at all. + A use-associated entity belongs to this module's interface when the + module's effective accessibility makes it public, which an ordinary + default-public module does without any access statement naming it. Each record also states what the name declares where it comes from, because only some kinds reach Python as one object to alias. """ @@ -1554,7 +1666,9 @@ def _module_reexports( *(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", ())} + is_public = cls._effective_accessibility(module) + dependencies = cls._module_declaration_dependencies(module) + explicit_public = {str(name).casefold() for name in module.public_symbols} index = module_index or {} reexports: list[SemanticReexport] = [] named: set[str] = set() @@ -1562,7 +1676,12 @@ def _module_reexports( 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: + local_key = local_name.casefold() + if ( + local_key in declared + or not is_public(local_name) + or (local_key in dependencies and local_key not in explicit_public) + ): continue kind, origin_module, origin_name = cls._resolve_reexport_origin(index, module_name, mapping.source) reexports.append( @@ -1574,7 +1693,16 @@ def _module_reexports( entity_kind=kind, ) ) - reexports.extend(cls._wildcard_reexports(module, index, declared=declared, published=published, named=named)) + reexports.extend( + cls._wildcard_reexports( + module, + index, + declared=declared, + dependencies=dependencies, + explicit_public=explicit_public, + named=named, + ) + ) return reexports @classmethod @@ -1624,14 +1752,15 @@ def _wildcard_reexports( index: dict[str, FortranModule], *, declared: set[str], - published: set[str], + dependencies: set[str], + explicit_public: set[str], named: set[str], ) -> list[SemanticReexport]: - """Return published names a plain ``use`` brought into this module. + """Return the names a plain ``use`` carried into this module and it publishes. 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 + reads, and this module's effective accessibility then decides which of + those it publishes in turn. A carried name is resolved only when one such module declares it: two that do leave the origin genuinely ambiguous, which is not something to guess at. """ @@ -1642,9 +1771,16 @@ def _wildcard_reexports( ] if not wildcard: return [] + is_public = cls._effective_accessibility(module) + carried = {name for used in wildcard for name in cls._module_public_names(used, index)} reexports: list[SemanticReexport] = [] - for name in sorted(published): - if name in declared or name in named: + for name in sorted(carried): + if ( + name in declared + or name in named + or not is_public(name) + or (name in dependencies and name not in explicit_public) + ): continue origins = [ origin diff --git a/tests/fortran/derived_types/codegen/test_derived_lowering.py b/tests/fortran/derived_types/codegen/test_derived_lowering.py index 9f499d2d0..465bc7f9b 100644 --- a/tests/fortran/derived_types/codegen/test_derived_lowering.py +++ b/tests/fortran/derived_types/codegen/test_derived_lowering.py @@ -204,7 +204,7 @@ class point: ) complete_semantic_policies(module) plan = WrapperPlanner().build(module) - variable = plan.namespaces[0].variables[0] + variable = plan.variables[0] assert variable.derived.handoff.origin is DerivedObjectOrigin.NATIVE_MODULE assert variable.derived.handoff.release is DerivedRelease.NATIVE_OWNER diff --git a/tests/fortran/derived_types/codegen/test_scalar_actual_dummy_plan.py b/tests/fortran/derived_types/codegen/test_scalar_actual_dummy_plan.py index c20b5d9ec..b64abad56 100644 --- a/tests/fortran/derived_types/codegen/test_scalar_actual_dummy_plan.py +++ b/tests/fortran/derived_types/codegen/test_scalar_actual_dummy_plan.py @@ -180,10 +180,10 @@ def test_exact_typed_value_is_not_restricted_to_bind_c_layout(): def test_module_actual_declarations_keep_distinct_runtime_storage(): - namespace = WrapperPlanner().build(_module()).namespaces[0] + plan = WrapperPlanner().build(_module()) storages = { variable.symbol_name: variable.derived.handoff.storage - for variable in namespace.variables + for variable in plan.variables if variable.derived is not None } assert storages == { diff --git a/tests/fortran/infrastructure/codegen/test_planner.py b/tests/fortran/infrastructure/codegen/test_planner.py index 461c3040a..7568ef3e2 100644 --- a/tests/fortran/infrastructure/codegen/test_planner.py +++ b/tests/fortran/infrastructure/codegen/test_planner.py @@ -83,7 +83,7 @@ def test_planner_keeps_one_module_variable_plan_for_multiple_publications(): plan = WrapperPlanner().build(module) - variables = [variable for namespace in plan.namespaces for variable in namespace.variables] + variables = list(plan.variables) publications = [ (namespace.python_path, publication.variable_owner_path, publication.python_names) for namespace in plan.namespaces @@ -96,6 +96,45 @@ def test_planner_keeps_one_module_variable_plan_for_multiple_publications(): ] +def test_module_variable_owner_is_its_native_identity_not_a_publication_path(): + """Adding a facade changes publications without moving native ownership.""" + + def planned_owner(*namespaces: str): + module = parse_pyi_text("values: Int32\n", module_name="package") + variable = module.variables[0] + variable.origin.native_scope = "home" + variable.origin.native_name = "values" + variable.metadata[PYTHON_EXPORTS_METADATA] = [ + {"namespace": (namespace,), "name": "values"} for namespace in namespaces + ] + complete_semantic_policies(module) + return WrapperPlanner().build(module) + + facade_only = planned_owner("facade") + facade_and_api = planned_owner("facade", "api") + + assert [variable.owner_path for variable in facade_only.variables] == ["home.values"] + assert [variable.owner_path for variable in facade_and_api.variables] == ["home.values"] + assert [variable.binding.support_namespace for variable in facade_only.variables] == [()] + assert [variable.binding.support_namespace for variable in facade_and_api.variables] == [()] + assert facade_only.entrypoint.support_procedures + assert [ + (procedure.owner_path, procedure.role, procedure.symbol_name) + for procedure in facade_only.entrypoint.support_procedures + ] == [ + (procedure.owner_path, procedure.role, procedure.symbol_name) + for procedure in facade_and_api.entrypoint.support_procedures + ] + assert { + (namespace.python_path, publication.variable_owner_path) + for namespace in facade_and_api.namespaces + for publication in namespace.variable_publications + } == { + (("api",), "home.values"), + (("facade",), "home.values"), + } + + def test_two_python_names_one_folded_stem_get_separate_generated_symbols(): """A generated symbol is shared with Fortran, which folds the two together.""" module = parse_pyi_text( diff --git a/tests/fortran/infrastructure/printers/test_source_printers.py b/tests/fortran/infrastructure/printers/test_source_printers.py index 22e2bedd9..003f9b68f 100644 --- a/tests/fortran/infrastructure/printers/test_source_printers.py +++ b/tests/fortran/infrastructure/printers/test_source_printers.py @@ -129,6 +129,7 @@ def test_source_printers_reject_wrapper_plan_models(): binding=BindingModulePlan("demo", "demo"), entrypoint=NativeEntrypointModulePlan("demo"), bridge=BridgeModulePlan("demo"), + variables=(), namespaces=(NamespacePlan(owner_path="demo", python_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 4df6dc892..268bd0419 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 @@ -1002,3 +1002,7 @@ def test_two_spellings_a_case_sensitive_source_keeps_apart_publish_separately(): assert published_name(published, "scale") == "scale" assert published_name(published, "Scale") == "scale" assert published_name(published, "missing") is None + # `FOO` could mean either declaration, and which one a folded lookup found + # would depend on the order they were recorded in, so it names neither. + assert published_name(published, "FOO") is None + assert published_name({"foo": "foo", "Foo": "Foo"}, "FOO") is None diff --git a/tests/fortran/memory_management/codegen/test_native_handle_planning.py b/tests/fortran/memory_management/codegen/test_native_handle_planning.py index fed0c2472..9b42d665b 100644 --- a/tests/fortran/memory_management/codegen/test_native_handle_planning.py +++ b/tests/fortran/memory_management/codegen/test_native_handle_planning.py @@ -311,7 +311,7 @@ def test_native_handle_plans_keep_datatype_specific_state(): def test_module_variables_use_borrowed_handle_plans_and_operation_sets(): plan = _module_handle_plan() - variables = {variable.symbol_name: variable for variable in plan.namespaces[0].variables} + variables = {variable.symbol_name: variable for variable in plan.variables} allocatable = variables["module_allocatable"].native_array_handle plain = variables["plain_allocatable"].native_array_handle names = variables["module_names"].native_array_handle @@ -488,7 +488,7 @@ def test_native_handle_plan_edits_fail_central_validation(edit: str, diagnostic: def test_plain_module_descriptor_view_requires_matching_completed_interop(): plan = _module_handle_plan() - plain = next(variable for variable in plan.namespaces[0].variables if variable.symbol_name == "plain_allocatable") + plain = next(variable for variable in plan.variables if variable.symbol_name == "plain_allocatable") assert plain.native_array_handle is not None plain.native_array_handle.descriptor_interop = NativeArrayDescriptorInterop.NONE plain.native_array_handle.required_headers = () diff --git a/tests/fortran/modules/codegen/test_module_array_view_lowering.py b/tests/fortran/modules/codegen/test_module_array_view_lowering.py index 841c41f7c..e137929a5 100644 --- a/tests/fortran/modules/codegen/test_module_array_view_lowering.py +++ b/tests/fortran/modules/codegen/test_module_array_view_lowering.py @@ -58,11 +58,7 @@ def _lowered_getters(): bridge = FortranBridgeGenerator() bridge.visit(plan) printer = FortranSourcePrinter() - return { - variable.binding.python_names[0]: printer.visit(bridge.visit(variable)[0]) - for namespace in plan.namespaces - for variable in namespace.variables - } + return {variable.bridge.native_name: printer.visit(bridge.visit(variable)[0]) for variable in plan.variables} def test_addressable_module_array_takes_its_address_directly(): @@ -148,15 +144,14 @@ def _undecided_plan(): module = parse_pyi_text("plain: Float64[3]\n", module_name="array_state") complete_semantic_policies(module) plan = WrapperPlanner().build(module) - variable = plan.namespaces[0].variables[0] + variable = plan.variables[0] return plan, variable, replace(variable, array_address=None) def test_module_array_view_plan_rejects_a_missing_address_mechanism(): """The plan boundary reports the gap rather than letting lowering guess.""" plan, _variable, undecided = _undecided_plan() - namespace = plan.namespaces[0] - namespace.variables = (undecided,) + plan.variables = (undecided,) diagnostics = WrapperGenerator()._plan_diagnostics(plan) diff --git a/tests/fortran/modules/codegen/test_scalar_module_variable_lowering.py b/tests/fortran/modules/codegen/test_scalar_module_variable_lowering.py index 30743a01d..2ad3cb240 100644 --- a/tests/fortran/modules/codegen/test_scalar_module_variable_lowering.py +++ b/tests/fortran/modules/codegen/test_scalar_module_variable_lowering.py @@ -78,16 +78,15 @@ def _source(artifacts, suffix: str) -> str: def _replace_variable(plan, python_name: str, edit): - root = plan.namespaces[0] variables = tuple( - edit(variable) if variable.binding.python_names == (python_name,) else variable for variable in root.variables + edit(variable) if variable.bridge.native_name == python_name else variable for variable in plan.variables ) - return replace(plan, namespaces=(replace(root, variables=variables), *plan.namespaces[1:])) + return replace(plan, variables=variables) def test_module_variable_plan_contains_only_completed_dispatch_facts(): plan = _plan() - variables = {variable.binding.python_names[0]: variable for variable in plan.namespaces[0].variables} + variables = {variable.bridge.native_name: variable for variable in plan.variables} assert variables["limit"].binding.getter_action is ModuleGetterAction.CONSTANT_VALUE assert variables["limit"].binding.setter_action is SetterAction.OMIT @@ -108,7 +107,7 @@ def test_module_variable_plan_contains_only_completed_dispatch_facts(): def test_symbolic_source_parameter_reuses_scalar_bridge_getter_for_module_initialization(): plan = _computed_constant_plan() - variables = {variable.binding.python_names[0]: variable for variable in plan.namespaces[1].variables} + variables = {variable.bridge.native_name: variable for variable in plan.variables} computed = variables["computed"] assert computed.binding.getter_action is ModuleGetterAction.NATIVE_CONSTANT_VALUE assert computed.binding.constant_value is None @@ -119,7 +118,7 @@ def test_symbolic_source_parameter_reuses_scalar_bridge_getter_for_module_initia c_source = _source(artifacts, ".c") fortran_source = _source(artifacts, ".f90") assert "int32_t bind_c_get_computed(void);" in c_source - assert "int32_t constant_computed_value_0 = bind_c_get_computed();" in c_source + assert "int32_t constant_computed_constants_computed_value_0 = bind_c_get_computed();" in c_source assert 'PyUnicode_FromString("D")' in c_source assert "native_computed => computed" in fortran_source assert "function bind_c_get_computed()" in fortran_source @@ -130,12 +129,7 @@ def test_symbolic_source_parameter_reuses_scalar_bridge_getter_for_module_initia def test_parameter_array_uses_one_immutable_python_owned_import_snapshot(): plan = _parameter_array_plan() - variable = next( - variable - for namespace in plan.namespaces - for variable in namespace.variables - if variable.binding.python_names == ("dpmpar",) - ) + variable = next(variable for variable in plan.variables if variable.bridge.native_name == "dpmpar") assert variable.binding.getter_action is ModuleGetterAction.NATIVE_CONSTANT_ARRAY_VALUE assert variable.binding.setter_action is SetterAction.OMIT assert variable.binding.constant_value is None @@ -146,10 +140,14 @@ def test_parameter_array_uses_one_immutable_python_owned_import_snapshot(): c_source = _source(artifacts, ".c") fortran_source = _source(artifacts, ".f90") assert "void * bind_c_get_dpmpar(int64_t * extent_0);" in c_source - assert "PyArray_EMPTY(1, constant_dpmpar_value_0_dimensions, NPY_FLOAT64, 1)" in c_source - assert "memcpy(PyArray_DATA((PyArrayObject *)constant_dpmpar_object_0)" in c_source - assert "PyArray_CLEARFLAGS((PyArrayObject *)constant_dpmpar_object_0, NPY_ARRAY_WRITEABLE)" in c_source - assert 'PyModule_AddObject(namespace_parameter_array, "dpmpar", constant_dpmpar_object_0)' in c_source + assert "PyArray_EMPTY(1, constant_parameter_array_dpmpar_value_0_dimensions, NPY_FLOAT64, 1)" in c_source + assert "memcpy(PyArray_DATA((PyArrayObject *)constant_parameter_array_dpmpar_object_0)" in c_source + assert ( + "PyArray_CLEARFLAGS((PyArrayObject *)constant_parameter_array_dpmpar_object_0, NPY_ARRAY_WRITEABLE)" in c_source + ) + assert ( + 'PyModule_AddObject(namespace_parameter_array, "dpmpar", constant_parameter_array_dpmpar_object_0)' in c_source + ) assert "real(c_double), allocatable, target, save, dimension(:) :: parameter_snapshot" in fortran_source assert "parameter_snapshot = native_dpmpar" in fortran_source assert "result = c_loc(parameter_snapshot)" in fortran_source @@ -157,9 +155,7 @@ def test_parameter_array_uses_one_immutable_python_owned_import_snapshot(): def test_module_variable_visitors_consume_their_backend_owned_actions(): plan = _plan() - counter = next( - variable for variable in plan.namespaces[0].variables if variable.binding.python_names == ("counter",) - ) + counter = next(variable for variable in plan.variables if variable.bridge.native_name == "counter") split_actions = replace( counter, binding=replace( @@ -185,9 +181,7 @@ def test_module_variable_visitors_consume_their_backend_owned_actions(): def test_fortran_module_setter_rejects_unsupported_bridge_assignment(): plan = _plan() - counter = next( - variable for variable in plan.namespaces[0].variables if variable.binding.python_names == ("counter",) - ) + counter = next(variable for variable in plan.variables if variable.bridge.native_name == "counter") invalid = replace(counter, bridge=replace(counter.bridge, native_assignment=AssignmentMode.ALIAS)) bridge = FortranBridgeGenerator() @@ -314,20 +308,15 @@ def test_missing_generated_support_procedure_fails_before_lowering(): def test_bridge_local_module_target_edit_does_not_change_the_c_boundary(): plan = _plan() baseline = _source(WrapperGenerator().generate(plan), ".c") - counter = next( - variable for variable in plan.namespaces[0].variables if variable.binding.python_names == ("counter",) - ) + counter = next(variable for variable in plan.variables if variable.bridge.native_name == "counter") edited_counter = replace( counter, bridge=replace(counter.bridge, native_name="counter_alternate"), ) - root = replace( - plan.namespaces[0], - variables=tuple( - edited_counter if variable is counter else variable for variable in plan.namespaces[0].variables - ), + edited = replace( + plan, + variables=tuple(edited_counter if variable is counter else variable for variable in plan.variables), ) - edited = replace(plan, namespaces=(root, *plan.namespaces[1:])) artifacts = WrapperGenerator().generate(edited) @@ -337,20 +326,11 @@ def test_bridge_local_module_target_edit_does_not_change_the_c_boundary(): def test_generator_rejects_python_module_setter_without_bridge_handoff(): plan = _plan() - counter = next( - variable for variable in plan.namespaces[0].variables if variable.binding.python_names == ("counter",) - ) + counter = next(variable for variable in plan.variables if variable.bridge.native_name == "counter") invalid_counter = replace(counter, entrypoint=replace(counter.entrypoint, setter_role=None)) invalid = replace( plan, - namespaces=( - replace( - plan.namespaces[0], - variables=tuple( - invalid_counter if variable is counter else variable for variable in plan.namespaces[0].variables - ), - ), - ), + variables=tuple(invalid_counter if variable is counter else variable for variable in plan.variables), ) with pytest.raises(ValueError, match="missing-module-setter-role"): 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 91f3df2a5..681df1ba5 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 @@ -761,7 +761,7 @@ def test_explicitly_published_import_is_reachable_without_a_second_wrapper(tmp_p 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. + A default-public module also republishes an accessible imported name. """ source = tmp_path / "reexport.f90" source.write_text(REEXPORT_SOURCE, encoding="utf-8") @@ -774,8 +774,7 @@ def test_explicitly_published_import_is_reachable_without_a_second_wrapper(tmp_p 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) + assert module.reexport_default_mod.scale_value is module.reexport_home_mod.scale_value # One wrapper defines the procedure; the facade only names it again. generated = (tmp_path / "build" / "reexport_wrapper.c").read_text(encoding="utf-8") @@ -816,11 +815,11 @@ def test_renamed_published_import_shares_the_wrapper_it_renames(tmp_path: Path): 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`. +def test_publishing_a_name_a_plain_use_brought_in_republishes_that_name(tmp_path: Path): + """A plain `use` carries public names that remain accessible by default. - 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. + An explicit `public` statement also publishes the named import; both routes + bind the one wrapper owned by the declaring module. """ source = tmp_path / "reexport.f90" source.write_text(REEXPORT_SOURCE, encoding="utf-8") @@ -832,8 +831,7 @@ def test_publishing_a_name_a_plain_use_brought_in_republishes_only_that_name(tmp 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) + assert module.reexport_default_mod.scale_value is module.reexport_home_mod.scale_value def test_publishing_an_already_published_import_follows_it_to_its_declaration(tmp_path: Path): diff --git a/tests/fortran/modules/semantics/test_reexport_accessibility.py b/tests/fortran/modules/semantics/test_reexport_accessibility.py new file mode 100644 index 000000000..e0d19c281 --- /dev/null +++ b/tests/fortran/modules/semantics/test_reexport_accessibility.py @@ -0,0 +1,203 @@ +"""Fortran accessibility decides which use-associated entities a module publishes. + +Accessibility is settled by precedence: an access statement naming the entity +decides it, otherwise the module's bare `public`/`private` default does, and +that default is itself `public`. Those rules cover a use-associated entity, so +an ordinary module publishes what it imports without naming it anywhere. +""" + +from pathlib import Path + +import pytest + +from prik.parsers.fortran import parse_fortran_project +from prik.semantics.fortran2ir import fortran_project_to_semantic_modules + +DECLARING = """\ +module a_mod + implicit none + integer :: x = 7 + integer :: y = 9 + type :: box + integer :: value + end type box +contains + integer function scale_value(v) + integer, intent(in) :: v + scale_value = v * 2 + end function scale_value +end module a_mod +""" + + +def _reexports(tmp_path: Path, importer: str) -> list[tuple[str, str, str]]: + """Return one importing module's re-exports as (local, source, origin).""" + source = tmp_path / "project.f90" + source.write_text(f"{DECLARING}\n{importer}", encoding="utf-8") + modules = fortran_project_to_semantic_modules(parse_fortran_project([source])) + importing = next(module for module in modules if module.name == "b_mod") + return [(item.local_name, item.source_name, item.origin_module) for item in importing.reexports] + + +def test_a_default_public_module_publishes_what_it_imports(tmp_path: Path): + """No access statement is needed: the module default is public.""" + assert _reexports( + tmp_path, + """\ +module b_mod + use a_mod, only : x + implicit none +end module b_mod +""", + ) == [("x", "x", "a_mod")] + + +def test_an_import_used_by_a_local_declaration_is_a_dependency(tmp_path: Path): + """An implicit public default does not turn declaration syntax into API.""" + assert ( + _reexports( + tmp_path, + """\ +module b_mod + use a_mod, only : crate => box + implicit none +contains + integer function crate_value(item) result(out) + type(crate), intent(in) :: item + out = item%value + end function crate_value +end module b_mod +""", + ) + == [] + ) + + +def test_explicit_public_still_publishes_a_declaration_dependency(tmp_path: Path): + """A named public statement is an explicit publication request.""" + assert _reexports( + tmp_path, + """\ +module b_mod + use a_mod, only : crate => box + implicit none + public :: crate +contains + integer function crate_value(item) result(out) + type(crate), intent(in) :: item + out = item%value + end function crate_value +end module b_mod +""", + ) == [("crate", "box", "a_mod")] + + +def test_a_bare_private_default_publishes_nothing_it_imports(tmp_path: Path): + """A bare `private` sets the default, which then covers the import.""" + assert ( + _reexports( + tmp_path, + """\ +module b_mod + use a_mod, only : x + implicit none + private +end module b_mod +""", + ) + == [] + ) + + +def test_an_access_statement_outranks_a_private_default(tmp_path: Path): + """Naming the entity decides it, whichever way the default points.""" + assert _reexports( + tmp_path, + """\ +module b_mod + use a_mod, only : x + implicit none + private + public :: x +end module b_mod +""", + ) == [("x", "x", "a_mod")] + + +def test_an_access_statement_outranks_a_public_default(tmp_path: Path): + """`private :: x` decides it even though the default is public.""" + assert ( + _reexports( + tmp_path, + """\ +module b_mod + use a_mod, only : x + implicit none + private :: x +end module b_mod +""", + ) + == [] + ) + + +def test_a_renamed_default_public_import_publishes_the_local_name(tmp_path: Path): + """A rename changes the name this module publishes, never the declaration.""" + assert _reexports( + tmp_path, + """\ +module b_mod + use a_mod, only : renamed => y + implicit none +end module b_mod +""", + ) == [("renamed", "y", "a_mod")] + + +def test_a_plain_use_carries_the_public_names_of_what_it_reads(tmp_path: Path): + """A `use` naming no list carries every public name, default rules applying.""" + carried = _reexports( + tmp_path, + """\ +module b_mod + use a_mod + implicit none +end module b_mod +""", + ) + + assert sorted(local for local, _source, _origin in carried) == ["box", "scale_value", "x", "y"] + + +def test_a_plain_use_under_a_private_default_carries_nothing(tmp_path: Path): + """The importing module's default decides what it publishes in turn.""" + assert ( + _reexports( + tmp_path, + """\ +module b_mod + use a_mod + implicit none + private +end module b_mod +""", + ) + == [] + ) + + +@pytest.mark.parametrize("kind", ["variable", "procedure"]) +def test_accessibility_decides_every_re_exportable_kind(kind: str, tmp_path: Path): + """The rule is about accessibility, so it does not single out one kind.""" + name = "x" if kind == "variable" else "scale_value" + published = _reexports( + tmp_path, + f"""\ +module b_mod + use a_mod, only : {name} + implicit none +end module b_mod +""", + ) + + assert published == [(name, name, "a_mod")] diff --git a/tests/fortran/pointers/codegen/test_pointer_lowering.py b/tests/fortran/pointers/codegen/test_pointer_lowering.py index 46dd5862b..be5368ae1 100644 --- a/tests/fortran/pointers/codegen/test_pointer_lowering.py +++ b/tests/fortran/pointers/codegen/test_pointer_lowering.py @@ -77,7 +77,7 @@ def select_pointer(n: Int32) -> Annotated[ def test_pointer_plans_complete_descriptor_ownership_and_operations_before_lowering(): plan = _pointer_plan() namespace = plan.namespaces[0] - module_pointer = namespace.variables[0].native_array_handle + module_pointer = plan.variables[0].native_array_handle functions = {function.binding.python_name: function for function in namespace.functions} pointer_result = functions["make_pointer"].results[0].native_array_handle pointer_output = functions["select_pointer"].results[0] From e98c87be5d406f974d79004d2494dadee6ea78dc Mon Sep 17 00:00:00 2001 From: said Date: Thu, 17 Sep 2026 06:13:33 +0100 Subject: [PATCH 41/96] codex: preserve TA-Lib reference inputs --- CHANGELOG.md | 4 +++ docs/user/examples/c/ta-lib-wrapper.md | 5 ++- examples/c/ta_lib/README.md | 8 +++-- examples/c/ta_lib/native_build.py | 34 ++++++++++++++++++-- examples/c/ta_lib/tests/test_native_build.py | 22 +++++++++++++ 5 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 examples/c/ta_lib/tests/test_native_build.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b6b5d7f0..5ecb90eaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ release tags add a leading `v` to the package version. ## Unreleased +- TA-Lib's pinned reference harness now preserves binary64 array inputs across + its preliminary JSON self-checks, preventing architecture-dependent BETA + mismatches without weakening the 322-indicator PRIK comparison. + - A module variable's canonical wrapper plan is now owned by its declaring native module and name. Adding, removing, or renaming Python facades changes only namespace publications, so support-operation and holder identities no diff --git a/docs/user/examples/c/ta-lib-wrapper.md b/docs/user/examples/c/ta-lib-wrapper.md index a7ab0b04b..a1eafb86d 100644 --- a/docs/user/examples/c/ta-lib-wrapper.md +++ b/docs/user/examples/c/ta-lib-wrapper.md @@ -369,7 +369,10 @@ The suite has four complementary layers: TA-Lib's runner performs abstraction-protocol self-checks before the indicator comparisons. That API is outside this example, so those setup requests are forwarded directly to the native reference server. They are not counted as -PRIK calls and cannot satisfy the required 322-name coverage set. +PRIK calls and cannot satisfy the required 322-name coverage set. The native +build helper configures this preliminary protocol to write array values with +17 significant digits, preserving its binary64 inputs across JSON without +changing the TA-Lib library used by either numerical comparison path. ## Tested platforms diff --git a/examples/c/ta_lib/README.md b/examples/c/ta_lib/README.md index 820061c2e..12e9d8f1e 100644 --- a/examples/c/ta_lib/README.md +++ b/examples/c/ta_lib/README.md @@ -147,7 +147,9 @@ wrapper builds, and makes the generated module importable. `build_prik.sh` performs three checked operations: 1. [`native_build.py`](native_build.py) fetches, verifies, builds, and caches - the pinned native release and its reference-test tools. + the pinned native release and its reference-test tools. The helper configures + the abstract-test JSON request writer for exact binary64 round trips; it does + not modify the TA-Lib library linked by either comparison path. 2. PRIK generates a complete public-header inventory for the pinned compiler target. The inventory is for the surface audit; it is not used as the wrapper contract. @@ -212,7 +214,9 @@ integer output arrays, while the session fixture checks `TA_Initialize` and The runner starts with abstraction-protocol self-checks. The adapter forwards those setup requests to the direct reference server because the abstraction API is explicitly excluded. They do not cross the generated wrapper and do -not count toward the required 322-indicator coverage set. +not count toward the required 322-indicator coverage set. Those self-checks use +17 significant digits for array values, so their JSON transport preserves the +runner's binary64 inputs exactly on every target. The detailed user guide includes the complete [test flow and CI target explanation](../../../docs/user/examples/c/ta-lib-wrapper.md#where-the-expected-results-come-from). diff --git a/examples/c/ta_lib/native_build.py b/examples/c/ta_lib/native_build.py index 2159db89f..091dc6127 100644 --- a/examples/c/ta_lib/native_build.py +++ b/examples/c/ta_lib/native_build.py @@ -18,6 +18,9 @@ TA_LIB_COMMIT = "2247d599bddf37ed37e3a709371517e46efc66f6" TA_LIB_REPOSITORY = "https://github.com/TA-Lib/ta-lib.git" DEFAULT_JOB_LIMIT = 8 +REFERENCE_HARNESS_REVISION = "abstract-json-binary64-roundtrip-v1" +_ABSTRACT_ARRAY_FORMAT = 'pos += snprintf(buf + pos, buf_size - pos, "%.15g", data[i]);' +_ROUNDTRIP_ABSTRACT_ARRAY_FORMAT = 'pos += snprintf(buf + pos, buf_size - pos, "%.17g", data[i]);' def _require_tool(name: str) -> str: @@ -76,6 +79,26 @@ def _verified_source(cache_root: Path, git: str) -> Path: return source +def _reference_runner_source(text: str) -> str: + """Preserve binary64 inputs across the pinned runner's JSON protocol.""" + original_count = text.count(_ABSTRACT_ARRAY_FORMAT) + roundtrip_count = text.count(_ROUNDTRIP_ABSTRACT_ARRAY_FORMAT) + if original_count == 0 and roundtrip_count == 1: + return text + if original_count != 1 or roundtrip_count != 0: + raise RuntimeError("pinned TA-Lib abstract-array serializer no longer matches the reviewed source") + return text.replace(_ABSTRACT_ARRAY_FORMAT, _ROUNDTRIP_ABSTRACT_ARRAY_FORMAT) + + +def _prepare_reference_runner(source: Path) -> None: + """Apply the reviewed protocol-only adjustment to TA-Lib's test runner.""" + path = source / "src" / "tools" / "ta_regtest" / "test_abstract.c" + current = path.read_text(encoding="utf-8") + prepared = _reference_runner_source(current) + if prepared != current: + path.write_text(prepared, encoding="utf-8") + + def _installed_library(prefix: Path) -> bool: include = prefix / "include" / "ta-lib" / "ta_libc.h" libraries = tuple((prefix / "lib").glob("libta-lib.*")) @@ -179,12 +202,19 @@ def build_ta_lib(compiler: str) -> tuple[Path, Path, Path, Path]: compiler = str(Path(compiler).resolve()) cache_root = _cache_root() source = _verified_source(cache_root, _require_tool("git")) + _prepare_reference_runner(source) key = _compiler_key(compiler) build = cache_root / f"build-{TA_LIB_TAG}-{key}" prefix = cache_root / f"install-{TA_LIB_TAG}-{key}" complete = prefix / ".prik-ta-lib-complete" + completion = f"{TA_LIB_TAG}\n{TA_LIB_COMMIT}\n{REFERENCE_HARNESS_REVISION}\n" runner, oracle = _reference_paths(build) - if complete.is_file() and _installed_library(prefix) and _reference_tools_built(build): + if ( + complete.is_file() + and complete.read_text(encoding="utf-8") == completion + and _installed_library(prefix) + and _reference_tools_built(build) + ): return prefix, runner, oracle, _shared_library(prefix) cmake = _require_tool("cmake") @@ -206,7 +236,7 @@ def build_ta_lib(compiler: str) -> tuple[Path, Path, Path, Path]: oracle = _build_reference_server(compiler, source, build) if not runner.is_file(): raise RuntimeError(f"TA-Lib build did not produce its regression runner at {runner}") - complete.write_text(f"{TA_LIB_TAG}\n{TA_LIB_COMMIT}\n", encoding="utf-8") + complete.write_text(completion, encoding="utf-8") return prefix, runner, oracle, _shared_library(prefix) diff --git a/examples/c/ta_lib/tests/test_native_build.py b/examples/c/ta_lib/tests/test_native_build.py new file mode 100644 index 000000000..d1f8e596c --- /dev/null +++ b/examples/c/ta_lib/tests/test_native_build.py @@ -0,0 +1,22 @@ +"""Pinned native-build preparation for the TA-Lib validation harness.""" + +import pytest + +from ..native_build import _reference_runner_source + + +def test_abstract_requests_preserve_binary64_inputs_across_json(): + source = 'pos += snprintf(buf + pos, buf_size - pos, "%.15g", data[i]);' + value = 0.12345678901234566 + + prepared = _reference_runner_source(source) + + assert prepared == 'pos += snprintf(buf + pos, buf_size - pos, "%.17g", data[i]);' + assert _reference_runner_source(prepared) == prepared + assert float(format(value, ".15g")) != value + assert float(format(value, ".17g")) == value + + +def test_abstract_protocol_adjustment_rejects_unreviewed_upstream_source(): + with pytest.raises(RuntimeError, match="no longer matches"): + _reference_runner_source("unrecognized serializer") From 338c973c0fef3da417f3299c816d68b48a80604f Mon Sep 17 00:00:00 2001 From: said Date: Thu, 17 Sep 2026 11:31:19 +0100 Subject: [PATCH 42/96] codex: separate Fortran accessibility from Python exports --- CHANGELOG.md | 12 +- docs/user/reference/pyi-format.md | 16 +- prik/pipeline/build.py | 4 + prik/planning/planner.py | 2 + prik/policy/exports.py | 50 ++++-- prik/printers/pyi.py | 4 +- prik/semantics/fortran2ir.py | 150 ++++++++++-------- prik/semantics/models.py | 28 +++- tests/fortran/_support/printer_models.py | 2 + .../arrays/semantics/test_array_semantics.py | 4 + .../end_to_end/test_type_accessibility.py | 51 +++++- .../semantics/test_enum_semantics.py | 2 + .../combined_modules/second_math.pyi | 2 +- .../end_to_end/test_multi_source_builds.py | 1 + .../general/expected/module_vars_use.json | 31 +++- .../test_export_and_initializer_policy.py | 28 ++++ .../test_pyi_printer_conversion_smoke.py | 2 + .../test_pyi_printer_imports_and_packages.py | 31 ++++ .../semantics/test_reexport_accessibility.py | 137 ++++++++++++++-- 19 files changed, 455 insertions(+), 102 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ecb90eaa..2c0096b9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,15 @@ release tags add a leading `v` to the package version. longer move between facades. - Generated Fortran contracts distinguish a module's dependencies from its - re-exports. An implicitly accessible imported name used by that module's own - declarations remains available to express them but is not published from the - importing module; naming it in a `public` statement still publishes it. + Python publications without changing Fortran accessibility. An implicitly + public imported name used by that module's own declarations remains + semantically reachable through the module and available to express the + contract, but reaches Python there only when named in a `public` statement. + +- Fortran use-association accessibility now honors `public` and `private` + statements that name an imported module, including entities reached through + multiple routes. Plain `use` discovery also carries named generic and + abstract interfaces through the semantic accessibility graph. - A contract may publish a module variable only through a facade, leaving the namespace declaring it out of Python entirely. Owning the one native variable diff --git a/docs/user/reference/pyi-format.md b/docs/user/reference/pyi-format.md index fd72f83d9..75f5aadd7 100644 --- a/docs/user/reference/pyi-format.md +++ b/docs/user/reference/pyi-format.md @@ -220,12 +220,16 @@ object state remain shared. A `Final[...]` parameter is published with the same constant semantics in every namespace. A generic is a dispatch surface rather than one object and remains publishable only by its declaring namespace. -PRIK writes the list into every generated contract, holding what the Fortran -source publishes: the module's own public declarations, explicitly public -imports, and other accessible imported names that are not dependencies of its -own declarations. For example, a type imported only to declare an argument -stays available as an import in the contract but is not published unless the -module names it in a `public` statement. Edit the list freely. +PRIK writes the list into every generated contract. It includes the module's +own public declarations, explicitly public imports, and accessible imported +names that are not dependencies of its own declarations. For example, a type +imported only to declare an argument stays available as an import in the +contract but is not published to Python unless the module names it in a +`public` statement. This Python publication choice does not change the name's +Fortran accessibility through the importing module. Fortran accessibility may +also name an imported module itself: making every route to an entity private +withholds it, while any explicitly public route keeps it accessible. Edit the +list freely. | Edit | Effect | | --- | --- | diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index 4f30f09de..5e22d85c9 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -77,6 +77,7 @@ ) from prik.policy.completion import _DEFERRED_C_DIRECT_DIAGNOSTIC_CODES, complete_semantic_policies from prik.policy.models import FunctionWrapperPolicy, NativeEntrypointAction +from prik.policy.exports import complete_reexport_publication_policy from prik.pipeline.pyi import _PyiSemanticModuleCache from prik.semantics.pyi_metadata import PYI_LOADED_METADATA from prik.planning import NativeGeneratedCodeGroupPlan, WrapperPlanner @@ -2362,6 +2363,7 @@ def _apply_source_python_exports(modules: list[SemanticModule]) -> None: procedures receive the root namespace; private declarations receive none. """ for module in modules: + complete_reexport_publication_policy(module, contract_named=False) module.metadata[PYTHON_EXPORTS_PREPARED_METADATA] = True namespace = (module.name.casefold(),) if module.origin.source_kind == "module" else () for declaration in _module_declarations(module): @@ -2381,6 +2383,8 @@ def _apply_source_python_exports(modules: list[SemanticModule]) -> None: } for module in modules: for reexport in module.reexports: + if not reexport.publishes_to_python(): + continue if reexport.entity_kind != "variable": continue variable = variables_by_identity.get( diff --git a/prik/planning/planner.py b/prik/planning/planner.py index 1ebd93ed0..66d6ad99d 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -596,6 +596,8 @@ def _aliases_by_namespace(self, module: models.SemanticModule) -> dict[tuple[str """ grouped = defaultdict(list) for reexport in module.reexports: + if not reexport.publishes_to_python(): + continue if reexport.entity_kind not in _ALIASABLE_REEXPORT_KINDS: continue source_namespace = tuple(part.casefold() for part in reexport.origin_module.split(".") if part) diff --git a/prik/policy/exports.py b/prik/policy/exports.py index 4a9c7b22e..e0cea2c2a 100644 --- a/prik/policy/exports.py +++ b/prik/policy/exports.py @@ -42,6 +42,7 @@ def complete_python_export_policy( choose, and only where the source language has no spelling of its own. """ contract_named = bool(module.metadata.get(PYI_LOADED_METADATA)) + complete_reexport_publication_policy(module, contract_named=contract_named) naming = NamingPolicy( strict_public_names=strict_wrapper_names, preserve_case=contract_named or preserves_source_case(module.origin.source_language), @@ -68,31 +69,60 @@ def complete_python_export_policy( _complete_reexport_names(module, naming, contract_named=contract_named) +def complete_reexport_publication_policy( + module: models.SemanticModule, + *, + contract_named: bool | None = None, +) -> None: + """Complete which public use associations become Python publications. + + Native Fortran keeps declaration dependencies semantically accessible but + does not expose them in the generated Python namespace unless an explicit + ``public`` statement names them. A loaded contract has already stated its + export surface, so every re-export record constructed from that surface is + published. + """ + if contract_named is None: + contract_named = bool(module.metadata.get(PYI_LOADED_METADATA)) + for reexport in module.reexports: + if reexport.python_exported is not None: + continue + reexport.python_exported = bool( + contract_named or not reexport.declaration_dependency or reexport.explicitly_public + ) + + def _complete_reexport_names( module: models.SemanticModule, naming: NamingPolicy, *, contract_named: bool, ) -> None: - """Name each re-export in the namespace that publishes it. + """Name each use-associated binding in its importing namespace. - A re-export adds no declaration, but it does add a Python attribute, so it - competes for a name with everything the publishing module declares. It is - reserved after those declarations: a module's own declaration keeps the - name it would have had, and an imported alias is the one moved aside. + Published associations add runtime attributes; dependency-only associations + still add contract imports. Both compete with declarations for a Python + spelling, so the same ledger names them after the module's declarations. A + dependency keeps an ordinary import-binding spelling even when the entity + is a type; only a published type receives class-style capitalization. """ for reexport in module.reexports: if reexport.python_name: continue - if reexport.entity_kind == "variable": + published = reexport.publishes_to_python() + if published and reexport.entity_kind == "variable": completed_name = _completed_variable_reexport_name(module, reexport) if completed_name is not None: reexport.python_name = completed_name continue - category = { - "derived_type": "class", - "variable": "variable", - }.get(reexport.entity_kind, "function") + category = ( + { + "derived_type": "class", + "variable": "variable", + }.get(reexport.entity_kind, "function") + if published + else "function" + ) reexport.python_name = naming.reserve_public_name( _reexport_namespace(module, reexport), reexport.local_name, diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index 387b9f418..6c237fbb3 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -289,7 +289,7 @@ def published_names(self, module: SemanticModule) -> dict[str, str]: for prototype in module.prototypes: names[str(prototype.name)] = str(prototype.name) for reexport in module.reexports: - if reexport.entity_kind == "prototype": + if reexport.entity_kind == "prototype" and reexport.publishes_to_python(): names[str(reexport.local_name)] = str(reexport.local_name) return names @@ -766,6 +766,8 @@ def _module_exported_names( names.append(self._callable_name(function, context)) names.extend(self._overload_set_name(overload_set, context) for overload_set in module.overload_sets) for reexport in module.reexports: + if not reexport.publishes_to_python(): + continue if self._is_source_kind_import(str(reexport.origin_module)): continue if reexport.entity_kind in _UNPUBLISHABLE_REEXPORT_KINDS: diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index e5104b178..0645f05b9 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -1535,28 +1535,43 @@ def procedures_to_semantic_module( @staticmethod def _effective_accessibility(module: FortranModule): - """Return whether one name is public in this module, by Fortran's rules. + """Return whether one name is public through its use-association routes. Accessibility is settled by precedence: an access statement naming the - entity decides it, otherwise the module's bare ``public``/``private`` - default does, and the default is itself ``public``. A use-associated - entity is covered by those same rules, so an ordinary default-public - module publishes what it imports without naming it anywhere. + entity decides it; otherwise any explicitly public module route makes + it public, while routes make it private only when every one is named + private. The module's bare default applies next and is itself public + when no bare statement appears. """ default_public = str(getattr(module, "default_visibility", "public")).casefold() != "private" explicit_public = {str(name).casefold() for name in getattr(module, "public_symbols", ())} explicit_private = {str(name).casefold() for name in getattr(module, "private_symbols", ())} - def is_public(name: object) -> bool: + def is_public(name: object, routes: Iterable[object] = ()) -> bool: folded = str(name).casefold() if folded in explicit_private: return False if folded in explicit_public: return True + route_names = {str(route).casefold() for route in routes} + if route_names & explicit_public: + return True + if route_names and route_names <= explicit_private: + return False return default_public return is_public + @staticmethod + def _module_declared_names(module: FortranModule) -> set[str]: + """Return the names declared by one module for accessibility resolution.""" + return { + *(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", ())), + *(interface.name.casefold() for interface in module.interfaces if interface.name is not None), + } + @classmethod def _module_declaration_dependencies(cls, module: FortranModule) -> set[str]: """Return imported names used to express this module's declarations. @@ -1628,71 +1643,68 @@ def _module_public_names( return set() seen = seen | {key} is_public = cls._effective_accessibility(module) - dependencies = cls._module_declaration_dependencies(module) - explicit_public = {str(name).casefold() for name in module.public_symbols} - offered = { - *(procedure.name for procedure in module.procedures), - *(derived.name for derived in module.derived_types), - *(variable.name for variable in getattr(module, "variables", ())), - *(mapping.local_name for mappings in module.uses.values() for mapping in mappings), - } - offered.update( - name - for module_name, mappings in module.uses.items() - if not mappings and module_name.casefold() in index - for name in cls._module_public_names(index[module_name.casefold()], index, seen) - ) - return { - str(name).casefold() - for name in offered - if is_public(name) and (str(name).casefold() not in dependencies or str(name).casefold() in explicit_public) - } + offered: dict[str, set[str]] = {name: set() for name in cls._module_declared_names(module)} + for module_name, mappings in module.uses.items(): + if mappings: + for mapping in mappings: + offered.setdefault(mapping.local_name.casefold(), set()).add(module_name) + continue + used = index.get(module_name.casefold()) + if used is None: + continue + for name in cls._module_public_names(used, index, seen): + offered.setdefault(name, set()).add(module_name) + return {name for name, routes in offered.items() if is_public(name, routes)} def _module_reexports( cls, module: FortranModule, module_index: dict[str, FortranModule] | None = None, ) -> list[SemanticReexport]: - """Return the imported names this module publishes. + """Return the public names this module accesses through ``use``. A use-associated entity belongs to this module's interface when the module's effective accessibility makes it public, which an ordinary default-public module does without any access statement naming it. - Each record also states what the name declares where it comes from, - because only some kinds reach Python as one object to alias. + Declaration use is recorded for later Python publication policy but + does not change this Fortran accessibility decision. """ - 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", ())), - } + declared = cls._module_declared_names(module) is_public = cls._effective_accessibility(module) dependencies = cls._module_declaration_dependencies(module) explicit_public = {str(name).casefold() for name in module.public_symbols} index = module_index or {} reexports: list[SemanticReexport] = [] named: set[str] = set() + named_mappings: dict[str, list[tuple[str, FortranUseMapping]]] = {} for module_name, mappings in module.uses.items(): for mapping in mappings: - local_name = mapping.local_name - named.add(local_name.casefold()) - local_key = local_name.casefold() - if ( - local_key in declared - or not is_public(local_name) - or (local_key in dependencies and local_key not in explicit_public) - ): - continue - kind, origin_module, origin_name = cls._resolve_reexport_origin(index, module_name, mapping.source) - reexports.append( - SemanticReexport( - local_name, - origin_module, - origin_name, - module.name, - entity_kind=kind, - ) + named_mappings.setdefault(mapping.local_name.casefold(), []).append((module_name, mapping)) + for local_key, routes in named_mappings.items(): + named.add(local_key) + local_name = routes[0][1].local_name + route_names = tuple(dict.fromkeys(module_name for module_name, _mapping in routes)) + if local_key in declared or not is_public(local_name, route_names): + continue + origins = { + cls._resolve_reexport_origin(index, module_name, mapping.source) for module_name, mapping in routes + } + known_origins = {origin for origin in origins if origin[0] != "unknown"} + if len(known_origins) > 1 or (not known_origins and len(origins) > 1): + continue + kind, origin_module, origin_name = next(iter(known_origins or origins)) + reexports.append( + SemanticReexport( + local_name, + origin_module, + origin_name, + module.name, + entity_kind=kind, + access_modules=list(route_names), + declaration_dependency=local_key in dependencies, + explicitly_public=local_key in explicit_public, ) + ) reexports.extend( cls._wildcard_reexports( module, @@ -1772,26 +1784,36 @@ def _wildcard_reexports( if not wildcard: return [] is_public = cls._effective_accessibility(module) - carried = {name for used in wildcard for name in cls._module_public_names(used, index)} + carried: dict[str, list[FortranModule]] = {} + for used in wildcard: + for name in cls._module_public_names(used, index): + carried.setdefault(name, []).append(used) reexports: list[SemanticReexport] = [] - for name in sorted(carried): - if ( - name in declared - or name in named - or not is_public(name) - or (name in dependencies and name not in explicit_public) - ): + for name, routes in sorted(carried.items()): + route_names = tuple(dict.fromkeys(used.name for used in routes)) + if name in declared or name in named or not is_public(name, route_names): continue - origins = [ + origins = { origin - for used in wildcard + for used in routes for origin in (cls._resolve_reexport_origin(index, used.name, name),) if origin[0] != "unknown" - ] + } if len(origins) != 1: continue - kind, origin_module, origin_name = origins[0] - reexports.append(SemanticReexport(name, origin_module, origin_name, module.name, entity_kind=kind)) + kind, origin_module, origin_name = next(iter(origins)) + reexports.append( + SemanticReexport( + name, + origin_module, + origin_name, + module.name, + entity_kind=kind, + access_modules=list(route_names), + declaration_dependency=name in dependencies, + explicitly_public=name in explicit_public, + ) + ) return reexports @staticmethod diff --git a/prik/semantics/models.py b/prik/semantics/models.py index 3e6b91bfe..232e2d510 100644 --- a/prik/semantics/models.py +++ b/prik/semantics/models.py @@ -684,10 +684,11 @@ class SemanticImport: @dataclass class SemanticReexport: - """Record one name a module publishes on behalf of the module it imports. + """Record one public use-associated name and its declaring entity. - 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. + Fortran accessibility determines whether the association exists here. + Python export policy separately decides whether the importing namespace + publishes it; declaration use must not erase the Fortran association. """ local_name: str @@ -715,6 +716,27 @@ class SemanticReexport: would misrepresent it. """ + access_modules: list[str] = field(default_factory=list) + """Immediate used-module routes through which the local name is accessible.""" + + declaration_dependency: bool = False + """Whether this module uses the local name to express a declaration.""" + + explicitly_public: bool = False + """Whether an entity-list ``public`` statement names the local name.""" + + python_exported: bool | None = None + """Completed post-IR decision to publish this association to Python.""" + + def publishes_to_python(self) -> bool: + """Return the completed Python publication decision.""" + if self.python_exported is None: + raise ValueError( + f"Python re-export policy for {self.module}.{self.local_name} is incomplete; " + "run complete_python_export_policy before consuming it" + ) + return self.python_exported + @dataclass class SemanticModule: diff --git a/tests/fortran/_support/printer_models.py b/tests/fortran/_support/printer_models.py index 01becd058..cd96c020a 100644 --- a/tests/fortran/_support/printer_models.py +++ b/tests/fortran/_support/printer_models.py @@ -20,6 +20,7 @@ ) from prik.policy.completion import complete_semantic_policies +from prik.policy.exports import complete_python_export_policy from tests.fortran._support.paths import FORTRAN_ROOT OPERATOR_F90_SOURCE = FORTRAN_ROOT / "generic_interfaces" / "end_to_end" / "fixtures" / "native" / "foperators_f90.f90" @@ -37,6 +38,7 @@ def generate_pyi(source: str) -> str: fmod = parse_fortran_source(source) smod = fortran_module_to_semantic_module(fmod) + complete_python_export_policy(smod) return emit_module(smod) diff --git a/tests/fortran/arrays/semantics/test_array_semantics.py b/tests/fortran/arrays/semantics/test_array_semantics.py index 901068c55..a885444c9 100644 --- a/tests/fortran/arrays/semantics/test_array_semantics.py +++ b/tests/fortran/arrays/semantics/test_array_semantics.py @@ -9,6 +9,7 @@ get_function, ) from prik.semantics.models import SemanticExpressionCallable +from prik.policy.exports import complete_python_export_policy from prik.printers import PyiPrinter from prik.parsers.fortran import parse_fortran_file as parse_fortran_source from prik.pipeline.pyi import pyi_text_to_semantic_module as parse_pyi_text @@ -171,6 +172,7 @@ def test_fortran_inquiries_become_python_array_expressions_and_keep_source_bound "2 if source.shape[1] > 0 else 1", "2 + source.shape[1] - 1 if source.shape[1] > 0 else 0", ] + complete_python_export_policy(module) generated = PyiPrinter().emit(module) assert "source.shape[0], max(1, source.shape[1]), source.size, 2 ** source.ndim" in generated assert "2 if source.shape[1] > 0 else 1" in generated @@ -226,6 +228,7 @@ def test_specification_function_calls_keep_local_and_imported_native_identity(): ], ] + complete_python_export_policy(module) generated = PyiPrinter().emit(module) reloaded = parse_pyi_text(generated, module_name="expression_owner") reloaded_array = get_function(reloaded, "values").return_type.storage.array @@ -267,6 +270,7 @@ def test_wildcard_specification_function_origin_round_trips_unambiguously(): assert array.expression_callables[0][0].native_scope == "extent_helpers" + complete_python_export_policy(module) generated = PyiPrinter().emit(module) reloaded = parse_pyi_text(generated, module_name="expression_owner") reloaded_array = get_function(reloaded, "values").return_type.storage.array diff --git a/tests/fortran/derived_types/end_to_end/test_type_accessibility.py b/tests/fortran/derived_types/end_to_end/test_type_accessibility.py index 958210d28..8e8e48ed2 100644 --- a/tests/fortran/derived_types/end_to_end/test_type_accessibility.py +++ b/tests/fortran/derived_types/end_to_end/test_type_accessibility.py @@ -7,7 +7,10 @@ import numpy as np import pytest -from tests.fortran._support.wrapper_build import _build_source_and_import +from prik.parsers.fortran import parse_fortran_project +from prik.pipeline.pyi import emit_module_stubs +from prik.semantics.fortran2ir import fortran_project_to_semantic_modules +from tests.fortran._support.wrapper_build import _build_source_and_import, _build_text_and_import pytestmark = pytest.mark.fortran_end_to_end @@ -18,6 +21,25 @@ "type_accessibility_wrapper.h", } +DEPENDENCY_SOURCE = """ +module dependency_home + implicit none + type :: box + integer :: value + end type box +end module dependency_home + +module dependency_consumer + use dependency_home, only : crate => box + implicit none +contains + integer function crate_value(item) result(value) + type(crate), intent(in) :: item + value = item%value + end function crate_value +end module dependency_consumer +""" + def test_accessibility_statements_shape_the_generated_class(tmp_path: Path): """Only components and bindings the type publishes reach Python. @@ -37,3 +59,30 @@ def test_accessibility_statements_shape_the_generated_class(tmp_path: Path): assert instance.peek() == np.int32(7) instance.step() assert instance.peek() == np.int32(8) + + +def test_declaration_dependency_accessibility_and_python_publication_are_separate(tmp_path: Path): + """The semantic route remains valid while runtime and contract omit its alias.""" + source = tmp_path / "dependency_accessibility.f90" + module = _build_text_and_import( + DEPENDENCY_SOURCE, + source.name, + tmp_path, + { + "bind_c_dependency_accessibility_wrapper.f90", + "dependency_accessibility_wrapper.c", + "dependency_accessibility_wrapper.h", + }, + ) + stubs = emit_module_stubs( + fortran_project_to_semantic_modules(parse_fortran_project([source])), + normalize_public_names=True, + ) + + consumer_contract = stubs["dependency_consumer"] + assert "from .dependency_home import Box as crate" in consumer_contract + assert consumer_contract.rstrip().endswith('__all__ = ["crate_value"]') + assert not any(name.casefold() == "crate" for name in vars(module.dependency_consumer)) + + item = module.dependency_home.Box(value=np.int32(7)) + assert module.dependency_consumer.crate_value(item) == np.int32(7) diff --git a/tests/fortran/enumerations/semantics/test_enum_semantics.py b/tests/fortran/enumerations/semantics/test_enum_semantics.py index afae9ebfb..918ca9a75 100644 --- a/tests/fortran/enumerations/semantics/test_enum_semantics.py +++ b/tests/fortran/enumerations/semantics/test_enum_semantics.py @@ -4,6 +4,7 @@ from prik.parsers.fortran import parse_fortran_file as parse_fortran_source +from prik.policy.exports import complete_python_export_policy from prik.printers import emit_module from prik.semantics.fortran2ir import fortran_module_to_semantic_module @@ -22,6 +23,7 @@ def test_fortran_enums_preserve_values_in_generated_pyi_contract(): ("yellow", "11"), ] assert constants["red"].semantic_type.metadata["fortran_bind_c"] is True + complete_python_export_policy(semantic) stub = emit_module(semantic) assert "color: Int32 = red" not in stub assert "color: Int32 = ..." in stub 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 8a7cf6b13..8a6aaf9dd 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 @@ -6,4 +6,4 @@ def double_after_add( value: Int32 ) -> Int32: ... -__all__ = ["double_after_add"] +__all__ = ["double_after_add", "add_one"] 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 56e36772f..574f6af8d 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 @@ -180,6 +180,7 @@ def _build_contract( def _assert_combined_runtime(module) -> None: assert module.first_math.add_one(np.int32(4)) == np.int32(5) + assert module.second_math.add_one is module.first_math.add_one assert module.second_math.double_after_add(np.int32(4)) == np.int32(10) box = module.shared_types.make_box(np.int32(7)) assert module.box_ops.box_value(box) == np.int32(7) 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 664800772..9417bdc91 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 @@ -34,7 +34,36 @@ }, "overload_sets": [], "prototypes": [], - "reexports": [], + "reexports": [ + { + "access_modules": [ + "iso_c_binding" + ], + "declaration_dependency": true, + "entity_kind": "unknown", + "explicitly_public": false, + "local_name": "c_int", + "module": "constants_mod", + "origin_module": "iso_c_binding", + "python_exported": null, + "python_name": "", + "source_name": "c_int" + }, + { + "access_modules": [ + "iso_c_binding" + ], + "declaration_dependency": true, + "entity_kind": "unknown", + "explicitly_public": false, + "local_name": "c_double", + "module": "constants_mod", + "origin_module": "iso_c_binding", + "python_exported": null, + "python_name": "", + "source_name": "c_double" + } + ], "variables": [ { "default_value": "100", diff --git a/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/policy/test_export_and_initializer_policy.py b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/policy/test_export_and_initializer_policy.py index 75d7c4de1..0201d68a7 100644 --- a/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/policy/test_export_and_initializer_policy.py +++ b/tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/policy/test_export_and_initializer_policy.py @@ -8,15 +8,43 @@ RESOLVED_SETTER_OWNERSHIP_POLICY_METADATA, SemanticFunction, SemanticModule, + SemanticReexport, SemanticType, SemanticVariable, ) +from prik.policy.exports import complete_python_export_policy from prik.policy.ownership import SetterAction from prik.policy.completion import complete_semantic_policies from tests.fortran._support.ownership_policy import _scalar_type from prik.semantics.models import RESOLVED_MODULE_VARIABLE_POLICY_METADATA +def test_reexport_policy_separates_fortran_accessibility_from_python_publication(): + dependency = SemanticReexport( + "box", + "home", + "box", + "consumer", + entity_kind="derived_type", + declaration_dependency=True, + ) + explicit = SemanticReexport( + "item", + "home", + "box", + "consumer", + entity_kind="derived_type", + declaration_dependency=True, + explicitly_public=True, + ) + module = SemanticModule("consumer", reexports=[dependency, explicit]) + + complete_python_export_policy(module) + + assert dependency.python_exported is False + assert explicit.python_exported is True + + def test_module_variable_initializer_policy_is_complete_before_ir_lowering(): module = SemanticModule( name="state", diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_conversion_smoke.py b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_conversion_smoke.py index adc55e585..d41226138 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_conversion_smoke.py +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_pyi_printer_conversion_smoke.py @@ -3,6 +3,7 @@ import pytest from prik.semantics.fortran2ir import fortran_module_to_semantic_module +from prik.policy.exports import complete_python_export_policy from prik.printers import emit_module from tests.fortran._support.fixture_outputs import parse_fixture @@ -24,4 +25,5 @@ def test_pyi_printer_conversion_smoke(fixture: Path): for module in parsed.modules: semantic_module = fortran_module_to_semantic_module(module) + complete_python_export_policy(semantic_module) emit_module(semantic_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 268bd0419..b87b3469c 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 @@ -13,6 +13,7 @@ opaque_dependency_modules, pyi_text_to_semantic_module as _parse_pyi_text, ) +from prik.policy.exports import complete_python_export_policy from prik.semantics import fortran_file_to_semantic_modules from prik.semantics.fortran2ir import fortran_module_to_semantic_module from prik.semantics.models import ( @@ -929,6 +930,35 @@ def test_generated_contract_states_the_names_its_source_publishes(): assert '__all__ = ["Box", "scale_value"]' in stubs["surface_home"] +def test_generated_contract_honors_used_module_accessibility_routes(): + """A module-name access statement controls names carried through that route.""" + modules = fortran_file_to_semantic_modules( + parse_fortran_source(""" +module route_home +integer :: x +end module route_home + +module route_hidden +use route_home +private :: route_home +end module route_hidden + +module route_visible +use route_home +private +public :: route_home +end module route_visible +""") + ) + + stubs = emit_module_stubs(modules, normalize_public_names=True) + + assert stubs["route_hidden"].rstrip().endswith("__all__ = []") + assert "from .route_home import x" not in stubs["route_hidden"] + assert "from .route_home import x" in stubs["route_visible"] + assert stubs["route_visible"].rstrip().endswith('__all__ = ["x"]') + + def test_a_published_intrinsic_name_states_no_contract_import(): """Publishing a name from an intrinsic module publishes nothing here. @@ -948,6 +978,7 @@ def test_a_published_intrinsic_name_states_no_contract_import(): """ module = fortran_module_to_semantic_module(parse_fortran_source(source)) + complete_python_export_policy(module) code = emit_module(module, normalize_public_names=True) assert [reexport.origin_module for reexport in module.reexports] == ["iso_fortran_env", "iso_fortran_env"] diff --git a/tests/fortran/modules/semantics/test_reexport_accessibility.py b/tests/fortran/modules/semantics/test_reexport_accessibility.py index e0d19c281..a61b94d45 100644 --- a/tests/fortran/modules/semantics/test_reexport_accessibility.py +++ b/tests/fortran/modules/semantics/test_reexport_accessibility.py @@ -30,12 +30,17 @@ """ -def _reexports(tmp_path: Path, importer: str) -> list[tuple[str, str, str]]: - """Return one importing module's re-exports as (local, source, origin).""" +def _reexports( + tmp_path: Path, + importer: str, + *, + module_name: str = "b_mod", +) -> list[tuple[str, str, str]]: + """Return one module's public use associations as (local, source, origin).""" source = tmp_path / "project.f90" source.write_text(f"{DECLARING}\n{importer}", encoding="utf-8") modules = fortran_project_to_semantic_modules(parse_fortran_project([source])) - importing = next(module for module in modules if module.name == "b_mod") + importing = next(module for module in modules if module.name == module_name) return [(item.local_name, item.source_name, item.origin_module) for item in importing.reexports] @@ -52,12 +57,11 @@ def test_a_default_public_module_publishes_what_it_imports(tmp_path: Path): ) == [("x", "x", "a_mod")] -def test_an_import_used_by_a_local_declaration_is_a_dependency(tmp_path: Path): - """An implicit public default does not turn declaration syntax into API.""" - assert ( - _reexports( - tmp_path, - """\ +def test_a_declaration_dependency_remains_a_public_use_association(tmp_path: Path): + """Using an import in a declaration does not change its accessibility.""" + assert _reexports( + tmp_path, + """\ module b_mod use a_mod, only : crate => box implicit none @@ -68,9 +72,28 @@ def test_an_import_used_by_a_local_declaration_is_a_dependency(tmp_path: Path): end function crate_value end module b_mod """, - ) - == [] - ) + ) == [("crate", "box", "a_mod")] + + +def test_a_third_module_resolves_a_declaration_dependency_through_its_importer(tmp_path: Path): + """A public use association remains available to another Fortran module.""" + assert _reexports( + tmp_path, + """\ +module b_mod + use a_mod, only : box + implicit none + type(box) :: stored +end module b_mod + +module c_mod + use b_mod, only : box + implicit none + type(box) :: another +end module c_mod +""", + module_name="c_mod", + ) == [("box", "box", "a_mod")] def test_explicit_public_still_publishes_a_declaration_dependency(tmp_path: Path): @@ -141,6 +164,64 @@ def test_an_access_statement_outranks_a_public_default(tmp_path: Path): ) +def test_a_private_used_module_route_withholds_its_entities(tmp_path: Path): + """Naming the only used-module route private makes its entities private.""" + assert ( + _reexports( + tmp_path, + """\ +module b_mod + use a_mod + implicit none + private :: a_mod +end module b_mod +""", + ) + == [] + ) + + +def test_a_public_used_module_route_outranks_the_private_default(tmp_path: Path): + """A public route exposes its entities despite the module's bare default.""" + published = _reexports( + tmp_path, + """\ +module b_mod + use a_mod + implicit none + private + public :: a_mod +end module b_mod +""", + ) + + assert sorted(local for local, _source, _origin in published) == ["box", "scale_value", "x", "y"] + + +def test_any_public_route_keeps_a_multiply_accessible_entity_public(tmp_path: Path): + """One public route wins when another route to the same entity is private.""" + assert _reexports( + tmp_path, + """\ +module left_mod + use a_mod, only : x +end module left_mod + +module right_mod + use a_mod, only : x +end module right_mod + +module b_mod + use left_mod + use right_mod + implicit none + private :: left_mod + public :: right_mod +end module b_mod +""", + ) == [("x", "x", "a_mod")] + + def test_a_renamed_default_public_import_publishes_the_local_name(tmp_path: Path): """A rename changes the name this module publishes, never the declaration.""" assert _reexports( @@ -169,6 +250,38 @@ def test_a_plain_use_carries_the_public_names_of_what_it_reads(tmp_path: Path): assert sorted(local for local, _source, _origin in carried) == ["box", "scale_value", "x", "y"] +def test_a_plain_use_carries_a_named_generic_interface(tmp_path: Path): + """The offered-name inventory includes named interface declarations.""" + carried = _reexports( + tmp_path, + """\ +module generic_home + implicit none + interface convert + module procedure convert_i + module procedure convert_r + end interface convert +contains + integer function convert_i(value) + integer, intent(in) :: value + convert_i = value + end function convert_i + real function convert_r(value) + real, intent(in) :: value + convert_r = value + end function convert_r +end module generic_home + +module b_mod + use generic_home + implicit none +end module b_mod +""", + ) + + assert ("convert", "convert", "generic_home") in carried + + def test_a_plain_use_under_a_private_default_carries_nothing(tmp_path: Path): """The importing module's default decides what it publishes in turn.""" assert ( From d74889130356dcb6e391db333822ff299328f666 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 17 Sep 2026 12:19:25 +0100 Subject: [PATCH 43/96] Judge a callback and an abstract interface by the same accessibility Three places still answered accessibility questions on their own terms. Resolving callback interfaces for an importing module filtered them by the explicit symbol statements and the module default alone. That misses what a `use` route decides: an entity reached through an explicitly public module is public whatever the bare default says, and one reached only through a module named private is not. Callback resolution now reads the same public-name result every other stage reads. The inventory of names a module offers took the name of each interface block, which an abstract block does not have. What such a block declares are the procedures inside it, which is exactly what another module imports to write a `procedure(...)` declaration, so those names join the inventory. A specific inside an ordinary generic stays out: there the generic is the name the block introduces. Resolving a name imported by several routes preferred whichever route this project could read. An unparsed module may carry the same entity or another one, so preferring the readable route guesses about the module nobody read. Routes that disagree now publish nothing, while a single unresolved route still names whatever the `use` reached. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- prik/semantics/fortran2ir.py | 37 +++-- .../semantics/test_reexport_accessibility.py | 145 ++++++++++++++++++ 2 files changed, 173 insertions(+), 9 deletions(-) diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 0645f05b9..f7165d766 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -746,6 +746,9 @@ def _module_callback_interfaces( ``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. + The accessibility is the one every other stage reads, so a name reached + through an explicitly public or private ``use`` route is judged the same + way here as anywhere else rather than by a separate calculation. """ key = module.name.casefold() if key in seen: @@ -760,11 +763,8 @@ def _module_callback_interfaces( ) if not exported_only: return visible - return { - name: resolved - for name, resolved in visible.items() - if cls._symbol_visibility(module, resolved.visible_name) == "public" - } + public = cls._module_public_names(module, modules) + return {name: resolved for name, resolved in visible.items() if resolved.visible_name.casefold() in public} @classmethod def _scope_callback_interfaces( @@ -1564,12 +1564,26 @@ def is_public(name: object, routes: Iterable[object] = ()) -> bool: @staticmethod def _module_declared_names(module: FortranModule) -> set[str]: - """Return the names declared by one module for accessibility resolution.""" + """Return the names declared by one module for accessibility resolution. + + A named interface block declares its generic. An abstract block names + no generic, and what it declares are the procedure signatures inside + it, which is what another module imports to write a ``procedure(...)`` + declaration. A specific inside an ordinary generic is not separately + declared here, because the generic is the name that block introduces. + """ return { *(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", ())), *(interface.name.casefold() for interface in module.interfaces if interface.name is not None), + *( + signature.name.casefold() + for interface in module.interfaces + if interface.name is None + for signature in interface.procedures + if signature.name + ), } @classmethod @@ -1689,10 +1703,15 @@ def _module_reexports( origins = { cls._resolve_reexport_origin(index, module_name, mapping.source) for module_name, mapping in routes } - known_origins = {origin for origin in origins if origin[0] != "unknown"} - if len(known_origins) > 1 or (not known_origins and len(origins) > 1): + # Every route has to name one entity. Routes that all resolve the + # same way name it; a single unresolved route still names whatever + # the ``use`` reached. Where routes disagree, or a resolved route + # sits beside one this project cannot read, the name means more + # than one thing here and choosing the readable one would be a + # guess about the module that was never parsed. + if len(origins) > 1: continue - kind, origin_module, origin_name = next(iter(known_origins or origins)) + kind, origin_module, origin_name = next(iter(origins)) reexports.append( SemanticReexport( local_name, diff --git a/tests/fortran/modules/semantics/test_reexport_accessibility.py b/tests/fortran/modules/semantics/test_reexport_accessibility.py index a61b94d45..8965c93ef 100644 --- a/tests/fortran/modules/semantics/test_reexport_accessibility.py +++ b/tests/fortran/modules/semantics/test_reexport_accessibility.py @@ -314,3 +314,148 @@ def test_accessibility_decides_every_re_exportable_kind(kind: str, tmp_path: Pat ) assert published == [(name, name, "a_mod")] + + +CALLBACK_HOME = """\ +module callback_types + implicit none + abstract interface + integer function unary(x) + integer, intent(in) :: x + end function unary + end interface +end module callback_types +""" + + +def _callback_reexports(tmp_path: Path, importer: str, *, module_name: str) -> list[tuple[str, str, str]]: + """Return one module's public use associations over an abstract-interface home.""" + source = tmp_path / "callbacks.f90" + source.write_text(f"{CALLBACK_HOME}\n{importer}", encoding="utf-8") + modules = fortran_project_to_semantic_modules(parse_fortran_project([source])) + importing = next(module for module in modules if module.name == module_name) + return [(item.local_name, item.source_name, item.origin_module) for item in importing.reexports] + + +def test_a_plain_use_carries_an_abstract_interface_procedure(tmp_path: Path): + """An abstract block names no generic; what it declares are its procedures.""" + assert _callback_reexports( + tmp_path, + """\ +module middle_mod + use callback_types + implicit none +end module middle_mod +""", + module_name="middle_mod", + ) == [("unary", "unary", "callback_types")] + + +def test_an_abstract_interface_procedure_survives_a_further_hop(tmp_path: Path): + """Carrying it once makes it importable by name from the carrying module.""" + assert _callback_reexports( + tmp_path, + """\ +module middle_mod + use callback_types + implicit none +end module middle_mod + +module user_mod + use middle_mod, only : unary + implicit none +end module user_mod +""", + module_name="user_mod", + ) == [("unary", "unary", "callback_types")] + + +def test_a_callback_reached_through_a_public_route_stays_public(tmp_path: Path): + """Callback accessibility is the module's accessibility, routes included. + + A bare `private` would hide the name were the used module not named public, + so judging it by the symbol statements alone reaches the wrong answer. + """ + assert _callback_reexports( + tmp_path, + """\ +module facade_mod + use callback_types + implicit none + private + public :: callback_types +end module facade_mod +""", + module_name="facade_mod", + ) == [("unary", "unary", "callback_types")] + + +def test_a_callback_reached_through_a_private_route_is_withheld(tmp_path: Path): + """Naming the used module private withholds what it carried, default aside.""" + assert ( + _callback_reexports( + tmp_path, + """\ +module facade_mod + use callback_types + implicit none + private :: callback_types +end module facade_mod +""", + module_name="facade_mod", + ) + == [] + ) + + +def test_routes_that_agree_on_one_entity_publish_it(tmp_path: Path): + """Two `use` statements naming the same declaration name one entity.""" + assert _reexports( + tmp_path, + """\ +module middle_mod + use a_mod, only : x + implicit none +end module middle_mod + +module b_mod + use a_mod, only : x + use middle_mod, only : x + implicit none +end module b_mod +""", + ) == [("x", "x", "a_mod")] + + +def test_a_readable_route_beside_an_unreadable_one_is_not_guessed(tmp_path: Path): + """An unparsed module may carry the same entity or another one. + + Choosing the readable route would be a guess about the one this project + cannot read, so the name is left out rather than resolved to either. + """ + assert ( + _reexports( + tmp_path, + """\ +module b_mod + use a_mod, only : x + use external_mod, only : x + implicit none +end module b_mod +""", + ) + == [] + ) + + +def test_a_single_unreadable_route_still_names_what_it_reached(tmp_path: Path): + """One route names one entity, whether or not this project can read it.""" + assert _reexports( + tmp_path, + """\ +module b_mod + use external_mod, only : y + implicit none +end module b_mod +""", + ) == [("y", "y", "external_mod")] From d43b6ded3817c9552d7909953bc63f10bc71df96 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 17 Sep 2026 13:30:53 +0100 Subject: [PATCH 44/96] Read a declaration's references rather than scan its text An imported name counts as a declaration dependency when this module's declarations reference it, which decides whether generating a contract publishes it. That was answered by scanning raw declaration text for identifiers, so a character literal spelling an imported name looked like a reference to it: a module importing `box` and declaring character(len=3), parameter :: label = "box" held `box` back from its contract for the literal alone. Parse the expression and take the names it reads. A literal's contents are then its value, which names nothing, while everything a declaration genuinely reads still counts. Text that is not an expression -- a kind selector such as `len=3` -- still falls back to scanning, with the literals removed first so a quoted spelling stays out either way. The helper belongs beside the rest of declaration-expression parsing, which already reasons about quoted text. This also fixes generation for contracts that a false dependency withheld a needed name from: a module publishing nothing a dependent contract imports cannot be built against, so `generate --pyi` failed rather than merely publishing less. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- prik/semantics/fortran2ir.py | 74 +++++++-- prik/utilities/declaration_expressions.py | 19 +++ .../test_declaration_expression_utilities.py | 20 +++ .../test_callback_route_resolution.py | 124 +++++++++++++++ .../semantics/test_reexport_accessibility.py | 141 ++++++++++++++++++ 5 files changed, 365 insertions(+), 13 deletions(-) create mode 100644 tests/fortran/callbacks/semantics/test_callback_route_resolution.py diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index f7165d766..d92deea7c 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -42,6 +42,7 @@ ArrayExpressionSource, canonicalize_declaration_extent, declaration_expression_calls, + declaration_expression_identifiers, fortran_extent_to_python, is_declaration_expression_helper, split_dimension_bounds, @@ -794,10 +795,23 @@ def _merge_imported_callback_interfaces( seen: frozenset[str], override: bool, ) -> None: - """Merge every interface one ``use`` list makes visible into ``visible``.""" + """Merge every interface one ``use`` list makes visible into ``visible``. + + A name reached by several ``use`` statements has to name one interface. + Routes are compared by the declaration they reach, so repeating a route + to the same interface is harmless while two different ones leave the + name meaning nothing here. A ``use`` of a module this project never + read is a route as well: it offers whatever it names, which nothing + here can compare, so it makes the name unresolved rather than letting a + readable route answer for it. + """ + declared_here = set(visible) + candidates: dict[str, set[tuple[str | None, str] | None]] = {} for module_name, mappings in uses.items(): source_module = modules.get(module_name.casefold()) if source_module is None: + for mapping in mappings: + candidates.setdefault(mapping.local_name.casefold(), set()).add(None) continue source_lookup = cls._module_callback_interfaces( modules, @@ -815,10 +829,20 @@ def _merge_imported_callback_interfaces( } ) for name, resolved in imported.items(): + candidates.setdefault(name, set()).add(cls._callback_identity(resolved)) if override: visible[name] = resolved else: visible.setdefault(name, resolved) + for name, identities in candidates.items(): + if name not in declared_here and len(identities) > 1: + visible.pop(name, None) + + @staticmethod + def _callback_identity(resolved: _CallbackInterface) -> tuple[str | None, str]: + """Return the declaration one resolved interface names.""" + owner = resolved.module.name.casefold() if resolved.module is not None else None + return (owner, resolved.native_name.casefold()) def _callback_semantic_type( self, @@ -1562,6 +1586,21 @@ def is_public(name: object, routes: Iterable[object] = ()) -> bool: return is_public + @staticmethod + def _module_interfaces(module: FortranModule): + """Return the interface blocks declared by the module itself. + + A block written inside a contained procedure belongs to that procedure, + so what it declares is reachable only there. Those blocks are stored + alongside the module's own, and including them would put a local name + into everything a ``use`` of this module can reach. + """ + return tuple( + interface + for interface in module.interfaces + if str(getattr(interface, "declaring_scope_kind", "module")).casefold() == "module" + ) + @staticmethod def _module_declared_names(module: FortranModule) -> set[str]: """Return the names declared by one module for accessibility resolution. @@ -1576,10 +1615,14 @@ def _module_declared_names(module: FortranModule) -> set[str]: *(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", ())), - *(interface.name.casefold() for interface in module.interfaces if interface.name is not None), + *( + interface.name.casefold() + for interface in FortranToIRConverter._module_interfaces(module) + if interface.name is not None + ), *( signature.name.casefold() - for interface in module.interfaces + for interface in FortranToIRConverter._module_interfaces(module) if interface.name is None for signature in interface.procedures if signature.name @@ -1593,8 +1636,12 @@ def _module_declaration_dependencies(cls, module: FortranModule) -> set[str]: The parser models retain declaration expressions but not executable statements here, so intersecting their identifiers with names visible through ``use`` distinguishes a dependency from an otherwise implicit - default-public re-export. An explicit ``public`` statement remains the - module's authoritative request to publish the name. + default-public re-export. Those identifiers come from parsing each + expression rather than scanning its text, so a name spelled inside a + character literal is read as part of that literal's value and not as a + reference to whatever it happens to spell. An explicit ``public`` + statement remains the module's authoritative request to publish the + name. """ declaration_text: list[str] = [] @@ -1641,7 +1688,9 @@ def add_procedure(procedure: FortranProcedureSignature) -> None: add_procedure(procedure) return { - identifier.casefold() for text in declaration_text for identifier in re.findall(r"\b[A-Za-z_]\w*\b", text) + identifier.casefold() + for text in declaration_text + for identifier in declaration_expression_identifiers(text) } @classmethod @@ -1812,12 +1861,11 @@ def _wildcard_reexports( route_names = tuple(dict.fromkeys(used.name for used in routes)) if name in declared or name in named or not is_public(name, route_names): continue - origins = { - origin - for used in routes - for origin in (cls._resolve_reexport_origin(index, used.name, name),) - if origin[0] != "unknown" - } + # Every route has to name one entity, the way a named import does. + # An unresolved route is kept in the comparison rather than + # discarded: dropping it would leave a readable route standing + # alone and answer for a module this project never read. + origins = {cls._resolve_reexport_origin(index, used.name, name) for used in routes} if len(origins) != 1: continue kind, origin_module, origin_name = next(iter(origins)) @@ -1843,7 +1891,7 @@ def _declared_entity_kind(declaring: FortranModule | None, source_name: str) -> key = source_name.casefold() if any(procedure.name.casefold() == key for procedure in declaring.procedures): return "procedure" - for interface in declaring.interfaces: + for interface in FortranToIRConverter._module_interfaces(declaring): if interface.abstract and any(signature.name.casefold() == key for signature in interface.procedures): return "prototype" if interface.name and interface.name.casefold() == key: diff --git a/prik/utilities/declaration_expressions.py b/prik/utilities/declaration_expressions.py index 87d069cd5..ec69bfcfc 100644 --- a/prik/utilities/declaration_expressions.py +++ b/prik/utilities/declaration_expressions.py @@ -61,6 +61,8 @@ def is_strided_extent(expression: str) -> bool: _ASSUMED_RANK_MARKER = "..." +_QUOTED_LITERAL = re.compile(r"'[^']*'|\"[^\"]*\"") +_IDENTIFIER_PATTERN = r"\b[A-Za-z_]\w*\b" # Every extent whose value only exists at run time, assumed rank included. RUNTIME_DIMENSION_MARKERS = RUNTIME_EXTENT_MARKERS | {_ASSUMED_RANK_MARKER} _FORTRAN_RELATIONAL_OPERATORS = { @@ -433,6 +435,23 @@ def declaration_extent_references(expression: str) -> tuple[str, ...]: ) +def declaration_expression_identifiers(expression: str) -> tuple[str, ...]: + """Return the names one declaration expression references. + + Parsing decides what is a reference: an identifier spelled inside a + character literal is part of the literal's value and names nothing, so + ``"box"`` references no ``box``. Text this stage cannot parse -- a kind + selector such as ``len=3``, for instance -- falls back to scanning + identifiers with the literals removed, so a quoted spelling stays out + either way. + """ + text = _python_parseable_fortran_expression(expression) + tree = _parse_expression(text) + if tree is not None: + return tuple(dict.fromkeys(node.id for node in ast.walk(tree) if isinstance(node, ast.Name))) + return tuple(dict.fromkeys(re.findall(_IDENTIFIER_PATTERN, _QUOTED_LITERAL.sub(" ", expression)))) + + def declaration_expression_calls(expression: str) -> tuple[str, ...]: """Return named call targets used by one Python-form declaration expression. diff --git a/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py b/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py index 14390ede1..21b3e67c1 100644 --- a/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py +++ b/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py @@ -6,6 +6,7 @@ import pytest from prik.utilities.declaration_expressions import ( + declaration_expression_identifiers, ArrayExpressionSource, DeclarationExpressionCall, ResolvedDeclarationExtent, @@ -320,3 +321,22 @@ def test_backend_renderer_rejects_invalid_target_and_unrenderable_syntax() -> No render_declaration_extent("not valid (", {}, target="c") with pytest.raises(ValueError, match="unsupported completed declaration-expression node"): render_declaration_extent("[n]", {}, target="c") + + +def test_a_character_literal_references_no_name_it_happens_to_spell(): + """Parsing decides what is a reference, so a literal's contents are its value.""" + assert declaration_expression_identifiers('"box"') == () + assert declaration_expression_identifiers("'box'") == () + + +def test_an_expression_reports_the_names_it_reads(): + """A name used in a declaration is a reference wherever it appears.""" + assert declaration_expression_identifiers("crate") == ("crate",) + assert set(declaration_expression_identifiers("n * 2 + other")) == {"n", "other"} + assert set(declaration_expression_identifiers("size(values)")) == {"size", "values"} + + +def test_unparseable_declaration_text_still_reports_names_outside_literals(): + """A kind selector is not an expression, so the names are scanned instead.""" + assert declaration_expression_identifiers("len=3") == ("len",) + assert declaration_expression_identifiers('kind="box"') == ("kind",) diff --git a/tests/fortran/callbacks/semantics/test_callback_route_resolution.py b/tests/fortran/callbacks/semantics/test_callback_route_resolution.py new file mode 100644 index 000000000..e9df5ae02 --- /dev/null +++ b/tests/fortran/callbacks/semantics/test_callback_route_resolution.py @@ -0,0 +1,124 @@ +"""A callback name reached by several `use` routes has to name one interface. + +Callback resolution answers which native declaration a `procedure(...)` names. +Routes are compared by the declaration they reach, so repeating a route is +harmless while two different ones, or one this project never read, leave the +name meaning nothing here. +""" + +from pathlib import Path + +from prik.parsers.fortran import parse_fortran_project +from prik.semantics.fortran2ir import FortranToIRConverter + +HOME = """\ +module known_callbacks + implicit none + abstract interface + subroutine cb(x) + integer, intent(in) :: x + end subroutine cb + end interface +end module known_callbacks + +module relay_mod + use known_callbacks, only : cb + implicit none +end module relay_mod +""" + + +def _visible_callbacks(tmp_path: Path, importer: str, *, module_name: str) -> dict[str, str | None]: + """Return the callback interfaces one module resolves, by declaring module.""" + source = tmp_path / "callbacks.f90" + source.write_text(f"{HOME}\n{importer}", encoding="utf-8") + project = parse_fortran_project([source]) + modules = [module for parsed in (getattr(project, "files", None) or [project]) for module in parsed.modules] + index = FortranToIRConverter._callback_module_index(modules) + importing = next(module for module in modules if module.name == module_name) + resolved = FortranToIRConverter._module_callback_interfaces(index, importing) + return {name: (item.module.name if item.module is not None else None) for name, item in resolved.items()} + + +def test_one_route_resolves_the_callback_it_names(tmp_path: Path): + """A single `use` names one interface, which is what the dummy declares.""" + assert _visible_callbacks( + tmp_path, + """\ +module single_mod + use known_callbacks, only : cb + implicit none +contains + subroutine go(f) + procedure(cb) :: f + end subroutine go +end module single_mod +""", + module_name="single_mod", + ) == {"cb": "known_callbacks"} + + +def test_routes_reaching_one_declaration_resolve_it(tmp_path: Path): + """Importing the same interface twice, directly and through a relay, is one entity.""" + assert _visible_callbacks( + tmp_path, + """\ +module agreeing_mod + use known_callbacks, only : cb + use relay_mod, only : cb + implicit none +contains + subroutine go(f) + procedure(cb) :: f + end subroutine go +end module agreeing_mod +""", + module_name="agreeing_mod", + ) == {"cb": "known_callbacks"} + + +def test_a_readable_route_beside_an_unreadable_one_resolves_nothing(tmp_path: Path): + """An unread module offers whatever it names, which nothing here can compare. + + The re-export graph already refuses to name an entity here, so resolving + the dummy against the readable route would answer a question the rest of + the conversion declined. + """ + assert ( + _visible_callbacks( + tmp_path, + """\ +module competing_mod + use known_callbacks, only : cb + use external_callbacks, only : cb + implicit none +contains + subroutine go(f) + procedure(cb) :: f + end subroutine go +end module competing_mod +""", + module_name="competing_mod", + ) + == {} + ) + + +def test_a_module_keeps_its_own_declaration_over_a_competing_import(tmp_path: Path): + """A module's own interface is what its declarations name, imports aside.""" + assert _visible_callbacks( + tmp_path, + """\ +module owning_mod + use known_callbacks, only : cb + use external_callbacks, only : cb + implicit none + abstract interface + subroutine cb(x) + integer, intent(in) :: x + end subroutine cb + end interface +end module owning_mod +""", + module_name="owning_mod", + ) == {"cb": "owning_mod"} diff --git a/tests/fortran/modules/semantics/test_reexport_accessibility.py b/tests/fortran/modules/semantics/test_reexport_accessibility.py index 8965c93ef..ead0d065d 100644 --- a/tests/fortran/modules/semantics/test_reexport_accessibility.py +++ b/tests/fortran/modules/semantics/test_reexport_accessibility.py @@ -459,3 +459,144 @@ def test_a_single_unreadable_route_still_names_what_it_reached(tmp_path: Path): end module b_mod """, ) == [("y", "y", "external_mod")] + + +def test_a_procedure_local_abstract_interface_stays_inside_its_procedure(tmp_path: Path): + """A block written inside a contained procedure declares a name only there. + + Those blocks are stored beside the module's own, so nothing but the + declaring scope distinguishes them. + """ + assert _reexports( + tmp_path, + """\ +module local_home + implicit none +contains + subroutine work() + abstract interface + subroutine local_callback() + end subroutine local_callback + end interface + end subroutine work +end module local_home + +module b_mod + use local_home + implicit none +end module b_mod +""", + ) == [("work", "work", "local_home")] + + +def test_a_procedure_local_generic_stays_inside_its_procedure(tmp_path: Path): + """A named generic declared inside a procedure is that procedure's, too.""" + carried = _reexports( + tmp_path, + """\ +module local_home + implicit none +contains + subroutine work() + interface local_generic + module procedure work + end interface local_generic + end subroutine work +end module local_home + +module b_mod + use local_home + implicit none +end module b_mod +""", + ) + + assert [local for local, _source, _origin in carried] == ["work"] + + +def test_a_wildcard_route_beside_an_unreadable_one_is_not_guessed(tmp_path: Path): + """A plain `use` compares routes the way a named import does. + + Discarding the unreadable route would leave the readable one standing + alone and answer for a module this project never read. + """ + assert ( + _reexports( + tmp_path, + """\ +module left_mod + use a_mod, only : x + implicit none +end module left_mod + +module right_mod + use external_mod, only : x + implicit none +end module right_mod + +module b_mod + use left_mod + use right_mod + implicit none +end module b_mod +""", + ) + == [] + ) + + +def test_wildcard_routes_that_agree_on_one_entity_publish_it(tmp_path: Path): + """Repeating a route to the same declaration names one entity.""" + assert _reexports( + tmp_path, + """\ +module left_mod + use a_mod, only : x + implicit none +end module left_mod + +module b_mod + use left_mod + use a_mod, only : x + implicit none +end module b_mod +""", + ) == [("x", "x", "a_mod")] + + +def test_a_name_spelled_inside_a_character_literal_is_not_a_dependency(tmp_path: Path): + """A literal's contents are its value, not a reference to what they spell.""" + source = tmp_path / "project.f90" + source.write_text( + f"""{DECLARING} +module b_mod + use a_mod, only : box + implicit none + character(len=3), parameter :: label = "box" +end module b_mod +""", + encoding="utf-8", + ) + modules = fortran_project_to_semantic_modules(parse_fortran_project([source])) + importing = next(module for module in modules if module.name == "b_mod") + + assert [(item.local_name, item.declaration_dependency) for item in importing.reexports] == [("box", False)] + + +def test_a_type_a_declaration_names_is_a_dependency(tmp_path: Path): + """Declaring with an imported type is what makes it a dependency.""" + source = tmp_path / "project.f90" + source.write_text( + f"""{DECLARING} +module b_mod + use a_mod, only : box + implicit none + type(box) :: item +end module b_mod +""", + encoding="utf-8", + ) + modules = fortran_project_to_semantic_modules(parse_fortran_project([source])) + importing = next(module for module in modules if module.name == "b_mod") + + assert [(item.local_name, item.declaration_dependency) for item in importing.reexports] == [("box", True)] From 5dd3a27020e8cc6e2ff869ef32159f5d4f3706f0 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 17 Sep 2026 14:04:33 +0100 Subject: [PATCH 45/96] Accept a raw address extent built from a supported call Whether a raw address array or string has a resolved extent was decided by scanning the extent's text for identifiers and requiring each to be a visible scalar argument. A call's own name is such an identifier, so `max(n, m)` was read as reading `max` alongside `n` and `m`, and refused because no argument is called `max`. Those are exactly the calls declaration support accepts. Read the extent's references instead, which the declaration-expression stage already reports: a call contributes its arguments rather than its name, and syntax that stage cannot resolve reports a name no argument carries, so an unknown name, a runtime extent and an unsupported call stay refused. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 7 +++++++ prik/policy/completion.py | 14 +++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c0096b9f..6c11cbba0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ release tags add a leading `v` to the package version. ## Unreleased +- A raw address contract accepts an extent built from the declaration calls + PRIK supports, so `Addr(Float64[max(n, m)])` and `Addr(Float64[abs(n)])` are + no longer refused as unresolved. Deciding that by scanning the extent's text + counted the call's own name among the values it reads, which no argument + carries. An extent naming something no argument supplies, a runtime extent, + and an unsupported call are refused as before. + - TA-Lib's pinned reference harness now preserves binary64 array inputs across its preliminary JSON self-checks, preventing architecture-dependent BETA mismatches without weakening the 322-indicator PRIK comparison. diff --git a/prik/policy/completion.py b/prik/policy/completion.py index 49b283fe8..2ba02d1fe 100644 --- a/prik/policy/completion.py +++ b/prik/policy/completion.py @@ -15,6 +15,7 @@ from collections.abc import Iterable from prik.semantics.scalar_types import SEMANTIC_SCALAR_TYPE_NAMES +from prik.utilities.declaration_expressions import declaration_extent_references from prik.policy.ownership import ( CodegenAction, OwnershipDecision, @@ -2120,12 +2121,19 @@ def _semantic_shape(semantic_type: models.SemanticType) -> list[str]: def _is_resolved_extent(value: object, visible_scalar_names: set[str]) -> bool: - """Report whether an extent is concrete or references only visible scalar inputs.""" + """Report whether an extent is concrete or references only visible scalar inputs. + + The references come from parsing the extent, which is what distinguishes a + value the extent reads from the name of a call it makes: ``max(n, m)`` + reads ``n`` and ``m``, and requiring ``max`` to be a visible scalar would + refuse an expression declaration support otherwise accepts. Syntax that + stage cannot resolve reports a name no argument carries, so it stays + refused. + """ text = str(value).strip() if not text or text in {":", "*", "...", ".."} or ":" in text: return False - names = set(re.findall(r"\b[A-Za-z_]\w*\b", text)) - return names <= visible_scalar_names + return set(declaration_extent_references(text)) <= visible_scalar_names def _complete_variable( From 3cc89a248d7fe77d690f1155b7a2de1d1679ac09 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 17 Sep 2026 14:04:56 +0100 Subject: [PATCH 46/96] Give each procedure the interfaces it declares, and read selectors as syntax Two scopes were being read as one. A block written inside a contained procedure is stored beside the module's own, so indexing the module's interfaces without regard to scope let two procedures that each declare `cb` share whichever came first -- giving one of them the other's argument types. The module now indexes only its own blocks, and each procedure adds the ones written inside it, so two procedures may name different interfaces the same way. A declaration selector writes its keyword before the value it carries, and that keyword names the slot rather than an entity. Reading `character(len=8)` as reading `len` made a module importing something called `len` look like it needed the import to express a declaration, which withheld the name from what the module publishes. Selectors now contribute the expression they carry, so `len=3` reads nothing, `kind=c_char` reads `c_char`, and a comparison stays a comparison. The same scanning answered which local parameters a signature depends on, so a parameter whose value is `"widen"` pulled in a parameter named `widen`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- prik/parsers/fortran/parser.py | 10 +++- prik/semantics/fortran2ir.py | 53 ++++++++++++++--- prik/utilities/declaration_expressions.py | 20 +++++++ .../test_declaration_expression_utilities.py | 21 +++++-- .../test_callback_route_resolution.py | 58 +++++++++++++++++++ 5 files changed, 149 insertions(+), 13 deletions(-) diff --git a/prik/parsers/fortran/parser.py b/prik/parsers/fortran/parser.py index 532413a64..486205aa0 100644 --- a/prik/parsers/fortran/parser.py +++ b/prik/parsers/fortran/parser.py @@ -18,6 +18,7 @@ from typing import ClassVar, Literal from prik.utilities.declaration_expressions import ( + declaration_expression_identifiers, evaluate_integer_expression, split_declaration_assignment, split_dimension_bounds, @@ -5497,11 +5498,16 @@ def _collect_relevant_local_params(sig: FortranProcedureSignature, local_params: @staticmethod def _extract_symbol_names(expr: str) -> set[str]: - """Extract lowercase identifier tokens from one expression.""" + """Return the lower-case names one expression reads. + + The names come from parsing, so a character literal's contents stay + part of its value: a parameter whose value is ``"widen"`` does not read + a parameter named ``widen``. + """ keywords = {"and", "or", "not"} return { token.lower() - for token in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", expr or "") + for token in declaration_expression_identifiers(expr or "") if not token.isdigit() and token.lower() not in keywords } diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index d92deea7c..e3d41bd4d 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -712,10 +712,21 @@ def _convert_data_member( def _declared_callback_interfaces( container: FortranModule | FortranFile, ) -> dict[str, _CallbackInterface]: - """Index interfaces declared directly in one module or file.""" + """Index interfaces declared directly in one module or file. + + A block written inside a contained procedure belongs to that procedure, + which may declare its own interface under a name another procedure uses + for a different one. Those blocks are stored beside the module's own, so + only the module's are indexed here and each procedure adds its own. + """ owner = container if isinstance(container, FortranModule) else None + blocks = ( + FortranToIRConverter._module_interfaces(container) + if isinstance(container, FortranModule) + else container.interfaces + ) lookup: dict[str, _CallbackInterface] = {} - for interface in container.interfaces: + for interface in blocks: for signature in interface.procedures: lookup.setdefault(signature.name.casefold(), _CallbackInterface(signature, owner)) if interface.name and len(interface.procedures) == 1: @@ -774,17 +785,39 @@ def _scope_callback_interfaces( uses: dict[str, list[FortranUseMapping]], *, base: dict[str, _CallbackInterface], + owner: FortranModule | None = None, + scope_name: str | None = None, ) -> dict[str, _CallbackInterface]: - """Extend a visible interface set with one inner scope's own imports. + """Extend a visible interface set with one inner scope's own declarations. - 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. + A procedure-local ``use``, and an interface block written inside the + procedure, both name the interface in that scope alone, so each takes + precedence over anything the enclosing scope made visible under the + same name. Two procedures may name different interfaces the same way, + which is why the enclosing module contributes only its own blocks. """ visible = dict(base) + for interface in cls._procedure_interfaces(owner, scope_name): + for signature in interface.procedures: + visible[signature.name.casefold()] = _CallbackInterface(signature, owner) + if interface.name and len(interface.procedures) == 1: + visible[interface.name.casefold()] = _CallbackInterface(interface.procedures[0], owner) cls._merge_imported_callback_interfaces(visible, modules, uses, seen=frozenset(), override=True) return visible + @staticmethod + def _procedure_interfaces(owner: FortranModule | None, scope_name: str | None): + """Return the interface blocks written inside one contained procedure.""" + if owner is None or scope_name is None: + return () + wanted = scope_name.casefold() + return tuple( + interface + for interface in owner.interfaces + if str(getattr(interface, "declaring_scope_kind", "module")).casefold() == "procedure" + and [part.casefold() for part in getattr(interface, "declaring_scope_path", ())][-1:] == [wanted] + ) + @classmethod def _merge_imported_callback_interfaces( cls, @@ -1364,7 +1397,13 @@ def _visit_FortranModule( derived_type_context=context, # 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), + callback_interfaces=self._scope_callback_interfaces( + index, + proc.uses, + base=callback_interfaces, + owner=module, + scope_name=proc.name, + ), ) for proc in source_procedures ] diff --git a/prik/utilities/declaration_expressions.py b/prik/utilities/declaration_expressions.py index ec69bfcfc..5418f2457 100644 --- a/prik/utilities/declaration_expressions.py +++ b/prik/utilities/declaration_expressions.py @@ -63,6 +63,7 @@ def is_strided_extent(expression: str) -> bool: _ASSUMED_RANK_MARKER = "..." _QUOTED_LITERAL = re.compile(r"'[^']*'|\"[^\"]*\"") _IDENTIFIER_PATTERN = r"\b[A-Za-z_]\w*\b" +_SELECTOR_KEYWORD = re.compile(r"\s*[A-Za-z_]\w*\s*=(?!=)") # Every extent whose value only exists at run time, assumed rank included. RUNTIME_DIMENSION_MARKERS = RUNTIME_EXTENT_MARKERS | {_ASSUMED_RANK_MARKER} _FORTRAN_RELATIONAL_OPERATORS = { @@ -445,6 +446,25 @@ def declaration_expression_identifiers(expression: str) -> tuple[str, ...]: identifiers with the literals removed, so a quoted spelling stays out either way. """ + names: list[str] = [] + for part in split_top_level_expression(expression, ","): + names.extend(_expression_identifiers(_selector_value(part))) + return tuple(dict.fromkeys(names)) + + +def _selector_value(part: str) -> str: + """Return the expression one declaration selector supplies. + + A selector writes its keyword before the value it carries, as ``len=n`` + and ``kind=c_char`` do. The keyword is syntax naming the slot rather than + an entity the declaration reads, so only what follows it is an expression. + """ + match = _SELECTOR_KEYWORD.match(part) + return part[match.end() :] if match is not None else part + + +def _expression_identifiers(expression: str) -> tuple[str, ...]: + """Return the names one expression reads, scanning only what will not parse.""" text = _python_parseable_fortran_expression(expression) tree = _parse_expression(text) if tree is not None: diff --git a/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py b/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py index 21b3e67c1..dd1bd11eb 100644 --- a/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py +++ b/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py @@ -336,7 +336,20 @@ def test_an_expression_reports_the_names_it_reads(): assert set(declaration_expression_identifiers("size(values)")) == {"size", "values"} -def test_unparseable_declaration_text_still_reports_names_outside_literals(): - """A kind selector is not an expression, so the names are scanned instead.""" - assert declaration_expression_identifiers("len=3") == ("len",) - assert declaration_expression_identifiers('kind="box"') == ("kind",) +def test_a_selector_keyword_names_a_slot_rather_than_an_entity(): + """`len` and `kind` are syntax, so only the value they carry is read.""" + assert declaration_expression_identifiers("len=3") == () + assert declaration_expression_identifiers("len=n") == ("n",) + assert declaration_expression_identifiers("kind=c_char") == ("c_char",) + assert declaration_expression_identifiers('kind="box"') == () + + +def test_each_selector_in_one_declaration_is_read_separately(): + """A character declaration carries both selectors in one stored string.""" + assert declaration_expression_identifiers("len=n, kind=c_char") == ("n", "c_char") + assert declaration_expression_identifiers("len=1, kind=c_char") == ("c_char",) + + +def test_a_comparison_is_not_read_as_a_selector(): + """`==` is an operator, so both sides are part of the expression.""" + assert set(declaration_expression_identifiers("a == b")) == {"a", "b"} diff --git a/tests/fortran/callbacks/semantics/test_callback_route_resolution.py b/tests/fortran/callbacks/semantics/test_callback_route_resolution.py index e9df5ae02..ce3572ef3 100644 --- a/tests/fortran/callbacks/semantics/test_callback_route_resolution.py +++ b/tests/fortran/callbacks/semantics/test_callback_route_resolution.py @@ -122,3 +122,61 @@ def test_a_module_keeps_its_own_declaration_over_a_competing_import(tmp_path: Pa """, module_name="owning_mod", ) == {"cb": "owning_mod"} + + +LOCAL_CALLBACKS = """\ +module local_mod + implicit none +contains + subroutine first(f) + abstract interface + subroutine cb(x) + integer, intent(in) :: x + end subroutine cb + end interface + procedure(cb) :: f + call f(1) + end subroutine first + + subroutine second(f) + abstract interface + subroutine cb(x) + real(8), intent(in) :: x + end subroutine cb + end interface + procedure(cb) :: f + call f(1.0d0) + end subroutine second +end module local_mod +""" + + +def test_each_procedure_resolves_the_callback_it_declares(tmp_path: Path): + """Two procedures may name different interfaces the same way. + + A block written inside a procedure belongs to it, and the parser stores + those beside the module's own, so indexing the module's blocks without + regard to scope lets whichever came first answer for both. + """ + source = tmp_path / "local_callbacks.f90" + source.write_text(LOCAL_CALLBACKS, encoding="utf-8") + project = parse_fortran_project([source]) + modules = [module for parsed in (getattr(project, "files", None) or [project]) for module in parsed.modules] + index = FortranToIRConverter._callback_module_index(modules) + owner = next(module for module in modules if module.name == "local_mod") + + # The module declares no interface of its own; both belong to a procedure. + assert FortranToIRConverter._declared_callback_interfaces(owner) == {} + + seen = {} + for procedure in owner.procedures: + scope = FortranToIRConverter._scope_callback_interfaces( + index, + procedure.uses, + base={}, + owner=owner, + scope_name=procedure.name, + ) + seen[procedure.name] = [argument.base_type for argument in scope["cb"].signature.arguments] + + assert seen == {"first": ["integer"], "second": ["real"]} From a763b31909a378181f4ad31e04bbd944e2ae3ad5 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 17 Sep 2026 17:18:12 +0100 Subject: [PATCH 47/96] Leave a character literal's contents out of lexical translation Three stages rewrote Fortran spellings that Python spells differently, and each did it over the whole text. A literal's contents are the value it carries, so translating them changes what the program says. A contract published `character(len=6), parameter :: text = ".true."` as `Final[String[6]] = 'True'`: a different value, and four characters where the same declaration states six. Declaration-expression translation turned `len(".true.")` into `len("True")` and `len("a%b")` into `len("a.b")`, so an expression built on a literal measured something else. Resolving compile-time symbols substituted inside literals too, so `len("runtime")` became `len("4")` wherever `runtime` named a value. Translate only outside the literals. The scanner lives beside the other quote-aware scanning in the declaration-expression utility and is shared by all three, and it reads a doubled quote as Fortran's escape rather than the end of the literal. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 7 ++++ prik/printers/pyi.py | 21 +++++++++--- prik/semantics/fortran2ir.py | 9 +++++- prik/utilities/declaration_expressions.py | 32 +++++++++++++++---- .../test_declaration_expression_utilities.py | 12 +++++++ .../semantics/test_reexport_accessibility.py | 12 +++++++ 6 files changed, 82 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c11cbba0..5cabcacd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ release tags add a leading `v` to the package version. ## Unreleased +- A generated contract states a character constant's own contents. Respelling + the Fortran spellings Python writes differently reached inside the literal + too, so `character(len=6), parameter :: text = ".true."` was published as + `Final[String[6]] = 'True'` -- a different value, and one contradicting its + own declared length. A logical or a real written the same way outside quotes + is respelled as before. + - A raw address contract accepts an extent built from the declaration calls PRIK supports, so `Addr(Float64[max(n, m)])` and `Addr(Float64[abs(n)])` are no longer refused as unresolved. Deciding that by scanning the extent's text diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index 6c237fbb3..56a21ed19 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -20,6 +20,7 @@ from prik.contracts import CONTRACT_SYMBOLS, CONTRACT_TYPE_NAMES from prik.naming import NamingPolicy from prik.naming.policy import normalize_public_name, preserves_source_case +from prik.utilities.declaration_expressions import outside_character_literals from prik.semantics.scalar_types import SEMANTIC_SCALAR_TYPE_NAMES from prik.semantics.ownership_metadata import ( OWNERSHIP_POLICY_METADATA, @@ -1407,20 +1408,32 @@ def _pyi_default_value(arg: SemanticVariable) -> str | None: @staticmethod def _python_literal_text(value: str | None) -> str | None: - """Handle python literal text for the current generation context.""" + """Return the Python spelling of one Fortran initializer. + + Only the text outside character literals is respelled. A literal's + contents are the constant's value, so a character parameter holding + ``".true."`` keeps six characters and one holding ``"1d2"`` keeps the + ``d`` it was written with, while a logical or a real written the same + way outside quotes is respelled as Python writes it. + """ if value is None: return None text = str(value).strip() if not text: return None - text = re.sub(r"\.true\.", "True", text, flags=re.IGNORECASE) - text = re.sub(r"\.false\.", "False", text, flags=re.IGNORECASE) - text = re.sub(r"(?<=\d)[dD](?=[+-]?\d)", "e", text) + text = outside_character_literals(text, PyiPrinter._respelled_fortran_literal) try: return ast.unparse(ast.parse(text, mode="eval").body) except SyntaxError: return None + @staticmethod + def _respelled_fortran_literal(text: str) -> str: + """Rewrite the Fortran literal spellings Python spells differently.""" + text = re.sub(r"\.true\.", "True", text, flags=re.IGNORECASE) + text = re.sub(r"\.false\.", "False", text, flags=re.IGNORECASE) + return re.sub(r"(?<=\d)[dD](?=[+-]?\d)", "e", text) + @staticmethod def _fortran_literal_text(value: str | None) -> str | None: """Return a Python literal spelling for literal Fortran initializer text.""" diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index e3d41bd4d..131f1a0f6 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -43,6 +43,7 @@ canonicalize_declaration_extent, declaration_expression_calls, declaration_expression_identifiers, + outside_character_literals, fortran_extent_to_python, is_declaration_expression_helper, split_dimension_bounds, @@ -288,7 +289,13 @@ def replace_symbol(match: re.Match[str]) -> str: token = match.group(0) return compile_time_values.get(token.lower(), token) - return re.sub(r"\b[A-Za-z_][A-Za-z0-9_]*\b", replace_symbol, raw) + def substitute(fragment: str) -> str: + return re.sub(r"\b[A-Za-z_][A-Za-z0-9_]*\b", replace_symbol, fragment) + + # A character literal's contents are data, so a symbol spelled inside one + # is not a reference to substitute: ``len("runtime")`` measures seven + # characters whatever value ``runtime`` names. + return outside_character_literals(raw, substitute) # Language-owned modules are contract vocabulary, not sibling contract leaves. diff --git a/prik/utilities/declaration_expressions.py b/prik/utilities/declaration_expressions.py index 5418f2457..3e8d22a13 100644 --- a/prik/utilities/declaration_expressions.py +++ b/prik/utilities/declaration_expressions.py @@ -17,7 +17,7 @@ import ast import re -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass __all__ = ( @@ -61,7 +61,7 @@ def is_strided_extent(expression: str) -> bool: _ASSUMED_RANK_MARKER = "..." -_QUOTED_LITERAL = re.compile(r"'[^']*'|\"[^\"]*\"") +_QUOTED_LITERAL = re.compile(r"'(?:[^']|'')*'|\"(?:[^\"]|\"\")*\"") _IDENTIFIER_PATTERN = r"\b[A-Za-z_]\w*\b" _SELECTOR_KEYWORD = re.compile(r"\s*[A-Za-z_]\w*\s*=(?!=)") # Every extent whose value only exists at run time, assumed rank included. @@ -655,8 +655,29 @@ def _python_parseable_fortran_expression(expression: str) -> str: translation. Unknown names and calls are intentionally retained for later provenance or policy diagnostics. """ - text = expression.strip() - text = _replace_fortran_array_constructors(text) + text = _replace_fortran_array_constructors(expression.strip()) + return outside_character_literals(text, _normalized_fortran_lexemes) + + +def outside_character_literals(text: str, transform: Callable[[str], str]) -> str: + """Apply one text transform to everything but the character literals. + + A literal's contents are its value, so lexical translation has to leave + them alone: ``len(".true.")`` measures six characters whatever ``.true.`` + means outside quotes. + """ + pieces: list[str] = [] + position = 0 + for literal in _QUOTED_LITERAL.finditer(text): + pieces.append(transform(text[position : literal.start()])) + pieces.append(literal.group(0)) + position = literal.end() + pieces.append(transform(text[position:])) + return "".join(pieces) + + +def _normalized_fortran_lexemes(text: str) -> str: + """Rewrite Fortran spellings that Python spells differently.""" text = re.sub(r"(?i)(?<=\d)_[A-Za-z]\w*\b", "", text) text = re.sub(r"(?i)(?<=\d)_[0-9]+\b", "", text) text = re.sub(r"(?i)\b(\d+(?:\.\d*)?)[dD]([+-]?\d+)\b", r"\1e\2", text) @@ -666,8 +687,7 @@ def _python_parseable_fortran_expression(expression: str) -> str: text = re.sub(re.escape(source), replacement, text, flags=re.IGNORECASE) for source, replacement in _FORTRAN_LOGICAL_OPERATORS.items(): text = re.sub(re.escape(source), replacement, text, flags=re.IGNORECASE) - text = text.replace("/=", "!=") - return text.replace("%", ".") + return text.replace("/=", "!=").replace("%", ".") def _qualified_call_name(node: ast.AST) -> str | None: diff --git a/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py b/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py index dd1bd11eb..3d4c2228c 100644 --- a/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py +++ b/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py @@ -353,3 +353,15 @@ def test_each_selector_in_one_declaration_is_read_separately(): def test_a_comparison_is_not_read_as_a_selector(): """`==` is an operator, so both sides are part of the expression.""" assert set(declaration_expression_identifiers("a == b")) == {"a", "b"} + + +def test_lexical_translation_leaves_character_literals_alone(): + """A literal's contents are its value, whatever they spell outside quotes.""" + from prik.utilities.declaration_expressions import _python_parseable_fortran_expression + + assert _python_parseable_fortran_expression('len(".true.")') == 'len(".true.")' + assert _python_parseable_fortran_expression('len("a%b")') == 'len("a%b")' + assert _python_parseable_fortran_expression('len("1d2")') == 'len("1d2")' + # Everything outside the literal is still translated. + assert _python_parseable_fortran_expression('obj%field + len("a%b")') == 'obj.field + len("a%b")' + assert _python_parseable_fortran_expression(".true.") == "True" diff --git a/tests/fortran/modules/semantics/test_reexport_accessibility.py b/tests/fortran/modules/semantics/test_reexport_accessibility.py index ead0d065d..1cff1c3f3 100644 --- a/tests/fortran/modules/semantics/test_reexport_accessibility.py +++ b/tests/fortran/modules/semantics/test_reexport_accessibility.py @@ -600,3 +600,15 @@ def test_a_type_a_declaration_names_is_a_dependency(tmp_path: Path): importing = next(module for module in modules if module.name == "b_mod") assert [(item.local_name, item.declaration_dependency) for item in importing.reexports] == [("box", True)] + + +def test_a_compile_time_symbol_is_not_substituted_inside_a_character_literal(): + """A literal's contents are data, so a symbol spelled there is not a reference.""" + from prik.semantics.fortran2ir import _resolve_compile_time_text + + values = {"runtime": "4"} + + assert _resolve_compile_time_text('len("runtime")', values) == 'len("runtime")' + # A reference outside the literal is still resolved. + assert _resolve_compile_time_text("runtime + 1", values) == "4 + 1" + assert _resolve_compile_time_text('len("runtime") + runtime', values) == 'len("runtime") + 4' From db494aaf09c0526951e4f5c1d47662a2d3765485 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 17 Sep 2026 17:19:01 +0100 Subject: [PATCH 48/96] Carry a character selector's length apart from its kind A character declaration writes two expressions inside one selector, and either may hold a comma of its own. The parser separated them correctly with its top-level splitter, then joined them back into the single `kind` field, leaving semantics to find the length again with a pattern that stops at the first comma. `character(len=max(4, n), kind=c_char)` therefore reached the semantic model as a length of `max(4`. Record the two apart where the top-level items are already known, and read that fact rather than searching the joined spelling. The separated length is resolved against the same compile-time symbols as the kind, so a declaration written `character(len=fixed)` still states the value `fixed` names. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- prik/parsers/fortran/models.py | 10 ++++ prik/parsers/fortran/parser.py | 50 +++++++++++++++-- prik/parsers/fortran/type_resolver.py | 29 ++++++++++ prik/semantics/fortran2ir.py | 14 ++--- .../test_fortran_string_semantics.py | 54 +++++++++++++++++++ 5 files changed, 146 insertions(+), 11 deletions(-) diff --git a/prik/parsers/fortran/models.py b/prik/parsers/fortran/models.py index 4b634ecee..769e9c841 100644 --- a/prik/parsers/fortran/models.py +++ b/prik/parsers/fortran/models.py @@ -254,6 +254,16 @@ def character_length_syntax(self) -> bool: """Whether the stored character ``kind`` text is actually a length.""" return bool(getattr(self, "_character_length_syntax", False)) + @property + def character_length_expression(self) -> str | None: + """The length a character declaration states, separated from its kind. + + A character selector carries two expressions, either of which may hold + commas of its own, so the parser records them apart rather than leaving + a later stage to split one joined spelling. + """ + return getattr(self, "_character_length_expression", None) + @property def polymorphic(self) -> bool: """Whether this variable was declared with Fortran ``class(...)``.""" diff --git a/prik/parsers/fortran/parser.py b/prik/parsers/fortran/parser.py index 486205aa0..1db8def16 100644 --- a/prik/parsers/fortran/parser.py +++ b/prik/parsers/fortran/parser.py @@ -44,7 +44,7 @@ FortranUseMapping, FortranVariable, ) -from prik.parsers.fortran.type_resolver import extract_kind_from_type_spec +from prik.parsers.fortran.type_resolver import extract_character_selector, extract_kind_from_type_spec from prik.parsers.fortran.utils import split_csv _PARSER_ARCHITECTURE_GUIDE = """ @@ -367,6 +367,7 @@ class _Declaration: explicit_visibility: str | None = None target_kind_expression: str | None = None character_length_syntax: bool = False + character_length_expression: str | None = None declared_storage_bits: int | None = None @@ -4285,8 +4286,14 @@ def _intrinsic_declaration(base_type: str, type_spec: str) -> _Declaration: base_type, extract_kind_from_type_spec(base_type, type_spec), ) - if base_type == "character" and type_spec and re.search(r"\bkind\s*=", type_spec, re.IGNORECASE) is None: - declaration.character_length_syntax = True + if base_type == "character" and type_spec: + # The selector's two expressions are separated while the top-level + # items are known, so no later stage has to split them back apart. + length, _kind = extract_character_selector(type_spec) + if length is not None: + declaration.character_length_expression = length + if re.search(r"\bkind\s*=", type_spec, re.IGNORECASE) is None: + declaration.character_length_syntax = True return declaration @staticmethod @@ -4310,8 +4317,12 @@ def _apply_type_spelling_metadata(var: FortranVariable, spelling: str) -> None: base_type, type_spec, _tail = intrinsic if base_type in {"double precision", "double complex"}: var._target_kind_expression = "kind(1.0d0)" - elif base_type == "character" and type_spec and re.search(r"\bkind\s*=", type_spec, re.IGNORECASE) is None: - var._character_length_syntax = True + elif base_type == "character" and type_spec: + length, _kind = extract_character_selector(type_spec) + if length is not None: + var._character_length_expression = length + if re.search(r"\bkind\s*=", type_spec, re.IGNORECASE) is None: + var._character_length_syntax = True @staticmethod def _apply_declaration_attributes( @@ -4408,6 +4419,8 @@ def _apply_internal_type_metadata(arg: FortranVariable, declaration: _Declaratio arg._target_kind_expression = declaration.target_kind_expression if declaration.character_length_syntax: arg._character_length_syntax = True + if declaration.character_length_expression is not None: + arg._character_length_expression = declaration.character_length_expression if declaration.declared_storage_bits is not None: arg._declared_storage_bits = declaration.declared_storage_bits if declaration.polymorphic: @@ -4778,12 +4791,15 @@ def _resolve_procedure_signature_types( for arg in sig.arguments: if arg.kind: arg.kind = self._resolve_kind_expression(arg.kind, local_params, resolver=local_resolver) + self._resolve_character_length(arg, local_params, resolver=local_resolver) if arg.shape: arg.shape = [local_resolver.resolve(dim) for dim in arg.shape] if arg.base_type == "unknown" and not state.implicit_none: arg.base_type = self._infer_implicit_base_type(arg.name) if sig.result and sig.result.kind: sig.result.kind = self._resolve_kind_expression(sig.result.kind, local_params, resolver=local_resolver) + if sig.result is not None: + self._resolve_character_length(sig.result, local_params, resolver=local_resolver) return self._collect_relevant_local_params(sig, local_params) def _reconcile_procedure_local_declarations( @@ -5340,6 +5356,7 @@ def _resolve_procedure_signature_facts( visible_symbols, resolver=resolver, ) + FortranParser._resolve_character_length(argument, visible_symbols, resolver=resolver) if resolve_shapes and argument.shape: argument.shape = [resolver.resolve(dimension) for dimension in argument.shape] if signature.result and signature.result.kind: @@ -5410,6 +5427,7 @@ def _resolve_module_like_compile_time_facts( visible, resolver=resolver, ) + FortranParser._resolve_character_length(variable, visible, resolver=resolver) if variable.shape: variable.shape = [resolver.resolve(dimension) for dimension in variable.shape] variable.lbound, variable.ubound = FortranParser._extract_bounds(variable.shape) @@ -5442,10 +5460,32 @@ def _resolve_derived_type_compile_time_facts( visible, resolver=resolver, ) + FortranParser._resolve_character_length(field, visible, resolver=resolver) if field.shape: field.shape = [resolver.resolve(dimension) for dimension in field.shape] field.lbound, field.ubound = FortranParser._extract_bounds(field.shape) + @staticmethod + def _resolve_character_length( + variable: FortranVariable, + symbols: Mapping[str, str], + *, + resolver: _CompileTimeResolver | None = None, + ) -> None: + """Resolve a separated character length against the kind's own symbols. + + The length is recorded apart from the kind, so it is resolved wherever + the kind is: a declaration written ``character(len=fixed)`` states the + value ``fixed`` names, the same as one written ``character(fixed)``. + """ + declared = getattr(variable, "_character_length_expression", None) + if not declared: + return + active_resolver = resolver or _CompileTimeResolver(symbols) + variable._character_length_expression = active_resolver.resolve( + FortranParser._resolve_symbol_reference(str(declared), symbols) + ) + @staticmethod def _resolve_kind_expression( expr: str, diff --git a/prik/parsers/fortran/type_resolver.py b/prik/parsers/fortran/type_resolver.py index 05a75f118..908d85adb 100644 --- a/prik/parsers/fortran/type_resolver.py +++ b/prik/parsers/fortran/type_resolver.py @@ -49,6 +49,35 @@ def extract_kind_from_type_spec(base_type: str, type_spec: str) -> str | None: return None +def extract_character_selector(type_spec: str) -> tuple[str | None, str | None]: + """Return one character declaration's length and kind expressions. + + The selector carries two independent expressions, either of which may + contain commas of its own, so they are separated here where the top-level + items are already known rather than rediscovered from a joined spelling. + A positional specifier states the length, which is what ``character(8)`` + and ``character(*)`` mean. + """ + if not type_spec: + return (None, None) + inside = type_spec[1:-1].strip() + if not inside: + return (None, None) + length: str | None = None + kind: str | None = None + for item in split_csv(inside): + key, separator, value = item.partition("=") + if not separator: + length = length or item.strip() or None + continue + keyword = key.strip().lower() + if keyword == "len": + length = value.strip() or None + elif keyword == "kind": + kind = value.strip() or None + return (length, kind) + + if __name__ == "__main__": examples = [ ("integer", "(4)"), diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 131f1a0f6..02f0545a5 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -541,14 +541,16 @@ def _convert_variable_type( def _character_length(self, var: FortranVariable) -> str: """Return the resolved character length recorded by a parsed declaration. - The helper reads the parser's mixed kind/length spelling, preferring an - explicit ``len=`` fragment and otherwise preserving legacy length syntax; - declarations with neither continue to use Fortran's length-one default. + The parser separates a character selector's length from its kind, so + the length is read from that fact rather than found again inside a + joined spelling, where an expression holding a comma of its own -- a + ``len=max(4, n)`` -- would be cut short. A declaration stating no + length continues to use Fortran's length-one default. """ + declared = getattr(var, "character_length_expression", None) + if declared: + return self._resolve_compile_time_text(str(declared)).strip() raw = self._resolve_compile_time_text(str(var.kind or "")).strip() - length_match = re.search(r"(?:^|,)\s*len\s*=\s*([^,]+)", raw, re.IGNORECASE) - if length_match is not None: - return length_match.group(1).strip() if var.character_length_syntax and raw: return raw return "1" diff --git a/tests/fortran/strings/semantics/test_fortran_string_semantics.py b/tests/fortran/strings/semantics/test_fortran_string_semantics.py index e7d28deaf..d701412b6 100644 --- a/tests/fortran/strings/semantics/test_fortran_string_semantics.py +++ b/tests/fortran/strings/semantics/test_fortran_string_semantics.py @@ -24,3 +24,57 @@ def test_scalar_character_inout_is_projected_as_replacement_return(): assert mapping.python_position == 0 assert mapping.native_position == 0 assert mapping.result_position == 0 + + +def test_a_character_length_keeps_the_commas_its_own_expression_holds(): + """The selector's two expressions are separated, not split back apart. + + `len=` and `kind=` are one parenthesized selector, and either may hold a + comma of its own, so finding the length inside a joined spelling cuts an + expression such as `max(4, n)` short at its first comma. + """ + parsed = parse_fortran_source( + """ +module selector_mod + use iso_c_binding, only : c_char + implicit none +contains + subroutine take(n, name) + integer, intent(in) :: n + character(len=max(4, n), kind=c_char), intent(in) :: name + end subroutine take +end module selector_mod +""", + filename="selector_mod.f90", + ) + + function = get_function(fortran_module_to_semantic_module(parsed.modules[0]), "take") + argument = next(item for item in function.arguments if item.name == "name") + + assert argument.semantic_type.metadata["fortran_character_length"] == "max(4, n)" + + +def test_every_character_declaration_form_states_its_own_length(): + """Separating the length leaves the ordinary spellings reading as before.""" + parsed = parse_fortran_source( + """ +module forms_mod + implicit none + integer, parameter :: fixed = 6 +contains + subroutine forms(a, b, c, d, e) + character(len=16), intent(in) :: a + character(8), intent(in) :: b + character(*), intent(in) :: c + character(len=fixed), intent(in) :: d + character, intent(in) :: e + end subroutine forms +end module forms_mod +""", + filename="forms_mod.f90", + ) + + function = get_function(fortran_module_to_semantic_module(parsed.modules[0]), "forms") + lengths = {item.name: item.semantic_type.metadata.get("fortran_character_length") for item in function.arguments} + + assert lengths == {"a": "16", "b": "8", "c": "*", "d": "6", "e": "1"} From 5301f3544fbd5bc1da77efc888b7cf48cab28513 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 17 Sep 2026 20:22:37 +0100 Subject: [PATCH 49/96] Read a Fortran character literal rather than hand it to Python Fortran doubles a quote to hold one, so `'don''t'` is five characters. Python reads that same spelling as two literals written side by side and joins them, dropping the quote, so a declaration written character(len=5), parameter :: word = 'don''t' was published as `Final[String[5]] = 'dont'` and returned `dont` -- four characters under a declared length of five, with the contract and the built extension agreeing only on the wrong value. Decode the literal where a whole one is recognized, and read the expression path only for text that is not one. Both the generated contract and the module constant now state `don't`, and a declaration holding no doubled quote is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 7 +++ prik/policy/construction.py | 6 +- prik/printers/pyi.py | 5 +- prik/utilities/declaration_expressions.py | 30 ++++++++++ .../test_character_constant_quoting.py | 57 +++++++++++++++++++ 5 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 tests/fortran/strings/end_to_end/test_character_constant_quoting.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cabcacd5..a327c72c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ release tags add a leading `v` to the package version. ## Unreleased +- A Fortran character constant reaches Python holding the characters it + declares. Fortran doubles a quote to hold one, which Python reads instead as + two literals written side by side and joins, so + `character(len=5), parameter :: word = 'don''t'` was published and returned + as `dont` -- four characters under a declared length of five. Both the + generated contract and the built extension now state `don't`. + - A generated contract states a character constant's own contents. Respelling the Fortran spellings Python writes differently reached inside the literal too, so `character(len=6), parameter :: text = ".true."` was published as diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 978fc39b8..850fc9a54 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -21,6 +21,7 @@ from prik.contracts import NATIVE_C_SCALAR_IDENTITIES from prik.naming import NamingPolicy, preserves_source_case +from prik.utilities.declaration_expressions import fortran_character_value from prik.semantics import models from prik.semantics.metadata import ( ADDRESS_ROLE_METADATA, @@ -7140,7 +7141,10 @@ def _scalar_module_literal_value(value: object, semantic_type_name: str) -> obje if lowered in {".false.", "false"}: return False if semantic_type_name == "String": - return ast.literal_eval(text) + # Fortran doubles a quote to hold one, which Python reads as two + # literals side by side and joins, dropping the quote. + character = fortran_character_value(text) + return character if character is not None else ast.literal_eval(text) normalized = text.replace("D", "e").replace("d", "e") parsed = ast.literal_eval(normalized) if semantic_type_name in {"Complex64", "Complex128"} and isinstance(parsed, tuple): diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index 56a21ed19..a36ae9d04 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -20,7 +20,7 @@ from prik.contracts import CONTRACT_SYMBOLS, CONTRACT_TYPE_NAMES from prik.naming import NamingPolicy from prik.naming.policy import normalize_public_name, preserves_source_case -from prik.utilities.declaration_expressions import outside_character_literals +from prik.utilities.declaration_expressions import fortran_character_value, outside_character_literals from prik.semantics.scalar_types import SEMANTIC_SCALAR_TYPE_NAMES from prik.semantics.ownership_metadata import ( OWNERSHIP_POLICY_METADATA, @@ -1421,6 +1421,9 @@ def _python_literal_text(value: str | None) -> str | None: text = str(value).strip() if not text: return None + character = fortran_character_value(text) + if character is not None: + return repr(character) text = outside_character_literals(text, PyiPrinter._respelled_fortran_literal) try: return ast.unparse(ast.parse(text, mode="eval").body) diff --git a/prik/utilities/declaration_expressions.py b/prik/utilities/declaration_expressions.py index 3e8d22a13..104198d04 100644 --- a/prik/utilities/declaration_expressions.py +++ b/prik/utilities/declaration_expressions.py @@ -452,6 +452,36 @@ def declaration_expression_identifiers(expression: str) -> tuple[str, ...]: return tuple(dict.fromkeys(names)) +def fortran_character_value(text: str) -> str | None: + """Return the value of one whole Fortran character literal, or ``None``. + + Fortran doubles a quote to hold one, so ``'don''t'`` is five characters. + Python reads that same spelling as two literals written side by side and + joins them, losing the quote, so a Fortran literal is decoded here rather + than handed to a Python reader. Text that is not one whole literal returns + ``None`` for the caller to read as an expression. + """ + stripped = text.strip() + if len(stripped) < 2 or stripped[0] != stripped[-1] or stripped[0] not in "\"'": + return None + quote = stripped[0] + body = stripped[1:-1] + index = 0 + value: list[str] = [] + while index < len(body): + character = body[index] + if character == quote: + # A lone quote ends the literal, so this is not one whole literal. + if index + 1 >= len(body) or body[index + 1] != quote: + return None + index += 2 + value.append(quote) + continue + value.append(character) + index += 1 + return "".join(value) + + def _selector_value(part: str) -> str: """Return the expression one declaration selector supplies. diff --git a/tests/fortran/strings/end_to_end/test_character_constant_quoting.py b/tests/fortran/strings/end_to_end/test_character_constant_quoting.py new file mode 100644 index 000000000..0b4a35768 --- /dev/null +++ b/tests/fortran/strings/end_to_end/test_character_constant_quoting.py @@ -0,0 +1,57 @@ +"""A character constant reaches Python holding the characters it declares. + +Fortran doubles a quote to hold one, so `'don''t'` is five characters. Python +reads that same spelling as two literals written side by side and joins them, +which silently drops the quote. Both the generated contract and the built +extension therefore decode the Fortran literal rather than hand its text to a +Python reader. +""" + +from pathlib import Path + +import pytest + +from prik.pipeline.build import build_fortran_extension +from tests.fortran._support.wrapper_build import _generate_checked_pyi_contract, _import_from_build_dir + +pytestmark = pytest.mark.fortran_end_to_end + +SOURCE = """\ +module quoting_mod + implicit none + character(len=5), parameter :: word = 'don''t' + character(len=3), parameter :: pair = "a""b" + character(len=4), parameter :: plain = 'abcd' +end module quoting_mod +""" + + +@pytest.fixture(scope="module") +def built(tmp_path_factory): + """Build the quoting source once for the read-only checks.""" + tmp_path = tmp_path_factory.mktemp("character_quoting") + source = tmp_path / "quoting.f90" + source.write_text(SOURCE, encoding="utf-8") + result = build_fortran_extension(source, output_dir=tmp_path / "build", output_name="quoting_api") + return _import_from_build_dir(result.module_name, result.output_dir) + + +def test_a_doubled_quote_reaches_python_as_one_quote(built): + """Each constant holds exactly the characters its declared length counts.""" + assert built.quoting_mod.word == "don't" + assert built.quoting_mod.pair == 'a"b' + assert built.quoting_mod.plain == "abcd" + + +def test_a_generated_contract_states_the_declared_characters(tmp_path: Path): + """The contract publishes the same value the extension returns.""" + source = tmp_path / "quoting.f90" + source.write_text(SOURCE, encoding="utf-8") + contracts = tmp_path / "contracts" + + _generate_checked_pyi_contract(source, contracts, None) + contract = (contracts / "quoting_mod.pyi").read_text(encoding="utf-8") + + assert 'word: Final[String[5]] = "don\'t"' in contract + assert "pair: Final[String[3]] = 'a\"b'" in contract + assert "plain: Final[String[4]] = 'abcd'" in contract From dbb45b5215382160cbf432a1ff0459e51c28879a Mon Sep 17 00:00:00 2001 From: said Date: Thu, 17 Sep 2026 20:24:23 +0100 Subject: [PATCH 50/96] Read a character declaration's kind as the selector separates it The parser now records a character selector's length and kind apart, but semantics still recovered the kind by searching the joined spelling with `(?:^|,)\s*kind\s*=\s*([^,]+)`. That pattern stops at the first comma, so a kind holding a comma of its own -- `character(len=8, kind=max(c_char, 1))` -- reached the type map as `max(c_char`, which names no kind and reported an unsupported kind under a spelling the source never wrote. Carry the kind expression forward from the parser's own split and read that fact, mirroring what the length already does. A declaration whose kind is an ordinary name, either selector order, a length-only selector, and a bare `character` are unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 6 ++++ prik/parsers/fortran/models.py | 5 +++ prik/parsers/fortran/parser.py | 26 +++++++++----- prik/semantics/fortran2ir.py | 35 ++++++++----------- .../semantics/test_compile_time_values.py | 5 ++- 5 files changed, 48 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a327c72c3..beca16ccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ release tags add a leading `v` to the package version. ## Unreleased +- A character declaration's kind is read whole, so + `character(len=8, kind=max(c_char, 1))` states the kind it writes. The kind + was found again inside the selector's joined spelling with a pattern that + stops at the first comma, cutting a kind expression holding a comma of its + own down to `max(c_char`. + - A Fortran character constant reaches Python holding the characters it declares. Fortran doubles a quote to hold one, which Python reads instead as two literals written side by side and joins, so diff --git a/prik/parsers/fortran/models.py b/prik/parsers/fortran/models.py index 769e9c841..596ab30ff 100644 --- a/prik/parsers/fortran/models.py +++ b/prik/parsers/fortran/models.py @@ -254,6 +254,11 @@ def character_length_syntax(self) -> bool: """Whether the stored character ``kind`` text is actually a length.""" return bool(getattr(self, "_character_length_syntax", False)) + @property + def character_kind_expression(self) -> str | None: + """The kind a character declaration states, separated from its length.""" + return getattr(self, "_character_kind_expression", None) + @property def character_length_expression(self) -> str | None: """The length a character declaration states, separated from its kind. diff --git a/prik/parsers/fortran/parser.py b/prik/parsers/fortran/parser.py index 1db8def16..b43cf8ab7 100644 --- a/prik/parsers/fortran/parser.py +++ b/prik/parsers/fortran/parser.py @@ -368,6 +368,7 @@ class _Declaration: target_kind_expression: str | None = None character_length_syntax: bool = False character_length_expression: str | None = None + character_kind_expression: str | None = None declared_storage_bits: int | None = None @@ -4289,9 +4290,11 @@ def _intrinsic_declaration(base_type: str, type_spec: str) -> _Declaration: if base_type == "character" and type_spec: # The selector's two expressions are separated while the top-level # items are known, so no later stage has to split them back apart. - length, _kind = extract_character_selector(type_spec) + length, kind = extract_character_selector(type_spec) if length is not None: declaration.character_length_expression = length + if kind is not None: + declaration.character_kind_expression = kind if re.search(r"\bkind\s*=", type_spec, re.IGNORECASE) is None: declaration.character_length_syntax = True return declaration @@ -4318,9 +4321,11 @@ def _apply_type_spelling_metadata(var: FortranVariable, spelling: str) -> None: if base_type in {"double precision", "double complex"}: var._target_kind_expression = "kind(1.0d0)" elif base_type == "character" and type_spec: - length, _kind = extract_character_selector(type_spec) + length, kind = extract_character_selector(type_spec) if length is not None: var._character_length_expression = length + if kind is not None: + var._character_kind_expression = kind if re.search(r"\bkind\s*=", type_spec, re.IGNORECASE) is None: var._character_length_syntax = True @@ -4421,6 +4426,8 @@ def _apply_internal_type_metadata(arg: FortranVariable, declaration: _Declaratio arg._character_length_syntax = True if declaration.character_length_expression is not None: arg._character_length_expression = declaration.character_length_expression + if declaration.character_kind_expression is not None: + arg._character_kind_expression = declaration.character_kind_expression if declaration.declared_storage_bits is not None: arg._declared_storage_bits = declaration.declared_storage_bits if declaration.polymorphic: @@ -5478,13 +5485,16 @@ def _resolve_character_length( the kind is: a declaration written ``character(len=fixed)`` states the value ``fixed`` names, the same as one written ``character(fixed)``. """ - declared = getattr(variable, "_character_length_expression", None) - if not declared: - return active_resolver = resolver or _CompileTimeResolver(symbols) - variable._character_length_expression = active_resolver.resolve( - FortranParser._resolve_symbol_reference(str(declared), symbols) - ) + for attribute in ("_character_length_expression", "_character_kind_expression"): + declared = getattr(variable, attribute, None) + if not declared: + continue + setattr( + variable, + attribute, + active_resolver.resolve(FortranParser._resolve_symbol_reference(str(declared), symbols)), + ) @staticmethod def _resolve_kind_expression( diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 02f0545a5..21b9b29cb 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -2411,9 +2411,9 @@ def _semantic_kind_key(self, var: FortranVariable) -> str | None: return None base_type = var.base_type.lower() - kind = self._resolve_compile_time_text(str(raw_kind)).strip().lower() if base_type == "character": - return FortranToIRConverter._character_kind_key(kind, character_length_syntax=var.character_length_syntax) + return self._character_kind_expression(var) + kind = self._resolve_compile_time_text(str(raw_kind)).strip().lower() if base_type == "logical": return "c_bool" if kind == "c_bool" else kind literal_kind = FortranToIRConverter._literal_kind_key(kind) @@ -2432,28 +2432,23 @@ def _target_type_key(self, var: FortranVariable) -> tuple[str, str | None]: if not raw_kind: return base_type, None - kind = self._resolve_compile_time_text(str(raw_kind)).strip().lower() if base_type == "character": - if var.character_length_syntax: - return base_type, None - kind_match = re.search(r"(?:^|,)\s*kind\s*=\s*([^,]+)", kind) - if kind_match is not None: - kind = kind_match.group(1).strip() - elif kind.startswith("len="): - return base_type, None + return base_type, self._character_kind_expression(var) + kind = self._resolve_compile_time_text(str(raw_kind)).strip().lower() return base_type, kind - @staticmethod - def _character_kind_key(kind: str, *, character_length_syntax: bool = False) -> str | None: - """Extract a character-kind key while ignoring length-only spellings.""" - if character_length_syntax: - return None - kind_match = re.search(r"(?:^|,)\s*kind\s*=\s*([^,]+)", kind) - if kind_match is not None: - kind = kind_match.group(1).strip() - elif re.match(r"^len\s*=", kind): + def _character_kind_expression(self, var: FortranVariable) -> str | None: + """Return the kind a character declaration states, or ``None`` for the default. + + The parser separates the selector's kind from its length, so the kind + is read from that fact rather than found again inside a joined + spelling, where an expression holding a comma of its own -- a + ``kind=max(c_char, 1)`` -- would be cut short. + """ + declared = getattr(var, "character_kind_expression", None) + if not declared: return None - return kind or None + return self._resolve_compile_time_text(str(declared)).strip().lower() or None def _target_type_fact(self, var: FortranVariable) -> dict[str, object] | None: """Return legacy fixed-width or configured compiler facts for ``var``.""" diff --git a/tests/fortran/infrastructure/semantic_ir/semantics/test_compile_time_values.py b/tests/fortran/infrastructure/semantic_ir/semantics/test_compile_time_values.py index 0d655502c..f2f629562 100644 --- a/tests/fortran/infrastructure/semantic_ir/semantics/test_compile_time_values.py +++ b/tests/fortran/infrastructure/semantic_ir/semantics/test_compile_time_values.py @@ -352,6 +352,9 @@ def test_semantic_compile_time_requirements_cover_all_parser_contexts(): ) == [] ) + # A character selector's kind is a parser fact separate from its length. + bad_character = FortranVariable(name="bad_character", base_type="character", kind="bad") + bad_character._character_kind_expression = "bad" unsupported = collect_semantic_compile_time_requirements( FortranFile( variables=[ @@ -359,7 +362,7 @@ def test_semantic_compile_time_requirements_cover_all_parser_contexts(): FortranVariable(name="bad_real", base_type="real", kind="bad"), FortranVariable(name="bad_complex", base_type="complex", kind="bad"), FortranVariable(name="bad_logical", base_type="logical", kind="bad"), - FortranVariable(name="bad_character", base_type="character", kind="bad"), + bad_character, FortranVariable(name="callback", base_type="procedure", kind="f_iface"), ] ) From 9ea35dcf7a060b4c338111ec09257ec091fe17fd Mon Sep 17 00:00:00 2001 From: said Date: Thu, 17 Sep 2026 20:24:58 +0100 Subject: [PATCH 51/96] Specialize declaration expressions, not every metadata string Compile-time specialization walked all semantic metadata and resolved every string it found as an expression. Metadata holds decisions already taken -- `fortran_pointer_association="runtime"`, a projection's native name, an enumerated policy choice -- and those are opaque values that only happen to spell identifiers. A module declaring `integer, parameter :: runtime` therefore rewrote its own pointer-association tag to `4`, turning a recorded decision into a number no stage can read back. Resolve the fields whose schema says they carry expression text: shapes, bounds, character lengths, initializers, and default values. Constraint arguments and projection values are user and native identities, so they are left as recorded, and a new metadata key holding an expression is added to `_EXPRESSION_METADATA_KEYS` rather than reached by default. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 7 ++ prik/semantics/fortran2ir.py | 57 ++++++------ .../semantics/test_compile_time_values.py | 89 +++++++++++++------ ...test_semantic_specialization_properties.py | 31 +++++-- 4 files changed, 125 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index beca16ccd..30a8a56ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ release tags add a leading `v` to the package version. ## Unreleased +- Compile-time specialization reaches only the fields that hold declaration + expressions. Every semantic metadata string was resolved as an expression, + so a recorded decision spelling a parameter's name -- a pointer's + `runtime` association in a module that also declares `integer, parameter :: + runtime` -- was replaced by that parameter's value. Shapes, bounds, + character lengths, initializers, and default values specialize as before. + - A character declaration's kind is read whole, so `character(len=8, kind=max(c_char, 1))` states the kind it writes. The kind was found again inside the selector's joined spelling with a pattern that diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 21b9b29cb..29a486718 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -4254,32 +4254,40 @@ def add_requirement( return requirements -def _resolve_semantic_value(value, compile_time_values: dict[str, str]): - """Recursively resolve compile-time text inside a semantic metadata value.""" - if isinstance(value, str): - return _resolve_compile_time_text(value, compile_time_values) - if isinstance(value, list): - return [_resolve_semantic_value(item, compile_time_values) for item in value] - if isinstance(value, tuple): - return tuple(_resolve_semantic_value(item, compile_time_values) for item in value) - if isinstance(value, dict): - return {key: _resolve_semantic_value(item, compile_time_values) for key, item in value.items()} - return value +#: Metadata keys whose value is declaration expression text, not an opaque tag. +#: +#: Compile-time specialization rewrites identifiers, so it may only reach a +#: field the schema says holds an expression. Every other metadata value -- +#: a policy tag, a native identity, an enumerated choice -- is opaque text +#: that happens to look like an identifier, and resolving it would silently +#: replace the recorded decision with a parameter's value. +_EXPRESSION_METADATA_KEYS = frozenset({"fortran_character_length", "fortran_initializer"}) + + +def _resolve_metadata_expressions( + metadata: dict[str, object], + compile_time_values: dict[str, str], +) -> dict[str, object]: + """Return ``metadata`` with only its declared expression fields resolved.""" + resolved = dict(metadata) + for key in _EXPRESSION_METADATA_KEYS & resolved.keys(): + value = resolved[key] + if isinstance(value, str): + resolved[key] = _resolve_compile_time_text(value, compile_time_values) + return resolved def _resolve_semantic_type_compile_time_values( semantic_type: SemanticType | None, compile_time_values: dict[str, str], ) -> None: - """Resolve shape, constraint, and storage text on one semantic type in place.""" + """Resolve shape, storage, and expression metadata on one semantic type in place.""" if semantic_type is None: return semantic_type.shape = [_resolve_compile_time_text(dim, compile_time_values) for dim in semantic_type.shape] - for constraint in semantic_type.constraints: - constraint.arguments = _resolve_semantic_value(constraint.arguments, compile_time_values) - semantic_type.metadata = _resolve_semantic_value(semantic_type.metadata, compile_time_values) + semantic_type.metadata = _resolve_metadata_expressions(semantic_type.metadata, compile_time_values) if semantic_type.storage is not None: - semantic_type.storage.metadata = _resolve_semantic_value( + semantic_type.storage.metadata = _resolve_metadata_expressions( semantic_type.storage.metadata, compile_time_values, ) @@ -4295,7 +4303,7 @@ def _resolve_semantic_type_compile_time_values( None if dim is None else _resolve_compile_time_text(dim, compile_time_values) for dim in array.upper_bounds ] - array.metadata = _resolve_semantic_value(array.metadata, compile_time_values) + array.metadata = _resolve_metadata_expressions(array.metadata, compile_time_values) def _resolve_semantic_argument_compile_time_values( @@ -4304,23 +4312,22 @@ def _resolve_semantic_argument_compile_time_values( ) -> None: """Resolve type, default, and metadata text on one semantic argument in place.""" _resolve_semantic_type_compile_time_values(arg.semantic_type, compile_time_values) - arg.default_value = _resolve_semantic_value(arg.default_value, compile_time_values) - arg.metadata = _resolve_semantic_value(arg.metadata, compile_time_values) + if isinstance(arg.default_value, str): + arg.default_value = _resolve_compile_time_text(arg.default_value, compile_time_values) + arg.metadata = _resolve_metadata_expressions(arg.metadata, compile_time_values) def _resolve_semantic_function_compile_time_values( func: SemanticFunction, compile_time_values: dict[str, str], ) -> None: - """Resolve all type-bearing fields and projection values on one function in place.""" + """Resolve all type-bearing fields on one function and its locals in place.""" for arg in func.arguments: _resolve_semantic_argument_compile_time_values(arg, compile_time_values) for local in func.locals: _resolve_semantic_argument_compile_time_values(local, compile_time_values) _resolve_semantic_type_compile_time_values(func.return_type, compile_time_values) - for mapping in func.projection: - mapping.value = _resolve_semantic_value(mapping.value, compile_time_values) - func.metadata = _resolve_semantic_value(func.metadata, compile_time_values) + func.metadata = _resolve_metadata_expressions(func.metadata, compile_time_values) def _resolve_semantic_module_compile_time_values( @@ -4337,8 +4344,8 @@ def _resolve_semantic_module_compile_time_values( _resolve_semantic_argument_compile_time_values(field, compile_time_values) for method in declaration.methods: _resolve_semantic_function_compile_time_values(method, compile_time_values) - declaration.metadata = _resolve_semantic_value(declaration.metadata, compile_time_values) - module.metadata = _resolve_semantic_value(module.metadata, compile_time_values) + declaration.metadata = _resolve_metadata_expressions(declaration.metadata, compile_time_values) + module.metadata = _resolve_metadata_expressions(module.metadata, compile_time_values) def resolve_semantic_compile_time_values( diff --git a/tests/fortran/infrastructure/semantic_ir/semantics/test_compile_time_values.py b/tests/fortran/infrastructure/semantic_ir/semantics/test_compile_time_values.py index f2f629562..719c1a3a7 100644 --- a/tests/fortran/infrastructure/semantic_ir/semantics/test_compile_time_values.py +++ b/tests/fortran/infrastructure/semantic_ir/semantics/test_compile_time_values.py @@ -396,7 +396,37 @@ def test_semantic_compile_time_requirements_cover_all_parser_contexts(): assert _compile_time_requirement_message("other", "n", "n + 1") == "Compile-time value required for 'n'." -def test_resolve_semantic_compile_time_values_rewrites_shapes_and_constraints(): +def test_resolve_semantic_compile_time_values_leaves_recorded_decisions_opaque(): + """A metadata tag is a decision already taken, not text awaiting a value. + + `fortran_pointer_association="runtime"` records how a pointer is + associated. A module that also declares `runtime` as a parameter must not + turn that recorded choice into the parameter's value. + """ + module = SemanticModule( + name="tagged_mod", + variables=[ + SemanticArgument( + name="view", + semantic_type=SemanticType( + name="Float64", + dtype="Float64", + rank=1, + shape=["runtime"], + metadata={"fortran_pointer_association": "runtime"}, + ), + ) + ], + ) + + resolved = resolve_semantic_compile_time_values(module, {"runtime": 4}) + + semantic_type = resolved.variables[0].semantic_type + assert semantic_type.shape == ["4"] + assert semantic_type.metadata == {"fortran_pointer_association": "runtime"} + + +def test_resolve_semantic_compile_time_values_rewrites_shapes(): module = SemanticModule( name="shape_mod", variables=[ @@ -428,6 +458,11 @@ def test_resolve_semantic_compile_time_values_rewrites_shapes_and_constraints(): def test_resolve_semantic_compile_time_values_handles_nested_modules(): + """Specialization reaches every nesting level and touches only expression fields. + + A metadata value that happens to spell a parameter name is a recorded + decision, not text to evaluate, so it survives at every level. + """ module = SemanticModule( name="nested_mod", variables=[ @@ -448,10 +483,10 @@ def test_resolve_semantic_compile_time_values_handles_nested_modules(): upper_bounds=["n"], ), ), - metadata={"bounds": ("n", ["m"])}, + metadata={"fortran_character_length": "n", "fortran_pointer_association": "n"}, ), default_value="n", - metadata={"alias": "m"}, + metadata={"fortran_initializer": "m", "address_role": "m"}, ) ], functions=[ @@ -461,15 +496,15 @@ def test_resolve_semantic_compile_time_values_handles_nested_modules(): SemanticArgument( name="x", semantic_type=SemanticType("Float64", rank=1, shape=["m"]), - metadata={"scale": "n"}, + metadata={"native_callback_kind": "n"}, ) ], - projection=[ProjectionMapping(value={"shape": ["n", ("m",)]})], - metadata={"work": ["n", {"inner": "m"}]}, + projection=[ProjectionMapping(value={"kind": "return", "name": "n", "position": 0})], + metadata={"import_scope": "n"}, ), SemanticFunction( name="with_result", - return_type=SemanticType("Int32", metadata={"extent": "n"}), + return_type=SemanticType("Int32", metadata={"fortran_character_length": "n"}), ), ], classes=[ @@ -485,43 +520,47 @@ def test_resolve_semantic_compile_time_values_handles_nested_modules(): methods=[ SemanticMethod( name="touch", - arguments=[SemanticArgument("self", SemanticType("state_t", metadata={"n": "n"}))], - return_type=SemanticType("Int32", metadata={"m": "m"}), - projection=[ProjectionMapping(value=("n", {"m": "m"}))], - metadata={"method": "n"}, + arguments=[SemanticArgument("self", SemanticType("state_t", metadata={"c_kind": "n"}))], + return_type=SemanticType("Int32", metadata={"fortran_character_length": "m"}), + metadata={"fortran_type_bound_target": "n"}, ) ], - metadata={"class": "m"}, + metadata={"fortran_attributes": "m"}, ) ], - metadata={"module": ["n", ("m",)]}, + metadata={"fortran_bind_c": "n"}, ) resolved = resolve_semantic_compile_time_values([module], {"n": 4, "m": 2}) assert module.variables[0].semantic_type.shape == ["n"] resolved_module = resolved[0] + + # Every level's declaration expressions are specialized. assert resolved_module.variables[0].semantic_type.shape == ["4"] assert resolved_module.variables[0].semantic_type.storage.array.shape == ["4"] assert resolved_module.variables[0].semantic_type.storage.array.source_shape == ["1:4"] assert resolved_module.variables[0].semantic_type.storage.array.lower_bounds == ["4"] assert resolved_module.variables[0].semantic_type.storage.array.upper_bounds == ["4"] - assert resolved_module.variables[0].semantic_type.metadata == {"bounds": ("4", ["2"])} + assert resolved_module.variables[0].semantic_type.metadata["fortran_character_length"] == "4" assert resolved_module.variables[0].default_value == "4" - assert resolved_module.variables[0].metadata == {"alias": "2"} + assert resolved_module.variables[0].metadata["fortran_initializer"] == "2" assert resolved_module.functions[0].arguments[0].semantic_type.shape == ["2"] - assert resolved_module.functions[0].arguments[0].metadata == {"scale": "4"} - assert resolved_module.functions[0].projection[0].value == {"shape": ["4", ("2",)]} - assert resolved_module.functions[0].metadata == {"work": ["4", {"inner": "2"}]} - assert resolved_module.functions[1].return_type.metadata == {"extent": "4"} + assert resolved_module.functions[1].return_type.metadata["fortran_character_length"] == "4" assert resolved_module.classes[0].fields[0].semantic_type.shape == ["4"] assert resolved_module.classes[0].fields[0].default_value == "2" - assert resolved_module.classes[0].methods[0].arguments[0].semantic_type.metadata == {"n": "4"} - assert resolved_module.classes[0].methods[0].return_type.metadata == {"m": "2"} - assert resolved_module.classes[0].methods[0].projection[0].value == ("4", {"m": "2"}) - assert resolved_module.classes[0].methods[0].metadata == {"method": "4"} - assert resolved_module.classes[0].metadata == {"class": "2"} - assert resolved_module.metadata == {"module": ["4", ("2",)]} + assert resolved_module.classes[0].methods[0].return_type.metadata["fortran_character_length"] == "2" + + # Recorded decisions are opaque at every level, however they are spelled. + assert resolved_module.variables[0].semantic_type.metadata["fortran_pointer_association"] == "n" + assert resolved_module.variables[0].metadata["address_role"] == "m" + assert resolved_module.functions[0].arguments[0].metadata == {"native_callback_kind": "n"} + assert resolved_module.functions[0].projection[0].value == {"kind": "return", "name": "n", "position": 0} + assert resolved_module.functions[0].metadata == {"import_scope": "n"} + assert resolved_module.classes[0].methods[0].arguments[0].semantic_type.metadata == {"c_kind": "n"} + assert resolved_module.classes[0].methods[0].metadata == {"fortran_type_bound_target": "n"} + assert resolved_module.classes[0].metadata == {"fortran_attributes": "m"} + assert resolved_module.metadata == {"fortran_bind_c": "n"} def test_module_parameters_preserve_literal_values_in_semantic_ir(): diff --git a/tests/fortran/infrastructure/semantic_ir/semantics/test_semantic_specialization_properties.py b/tests/fortran/infrastructure/semantic_ir/semantics/test_semantic_specialization_properties.py index b28f416e0..f3d2221a8 100644 --- a/tests/fortran/infrastructure/semantic_ir/semantics/test_semantic_specialization_properties.py +++ b/tests/fortran/infrastructure/semantic_ir/semantics/test_semantic_specialization_properties.py @@ -53,39 +53,52 @@ def test_generated_semantic_specialization_is_non_mutating_and_idempotent(n, m): rank=2, shape=["1:n", "m + 1"], constraints=[SemanticConstraint("Extent", ["n", {"upper": "m"}])], - metadata={"bounds": ("n", ["m"])}, + metadata={"fortran_character_length": "n", "enum_name": "n"}, storage=SemanticStorageContract( kind="array", - metadata={"extent": "n"}, + metadata={"address_role": "n"}, array=SemanticArrayContract( rank=2, shape=["1:n", "m + 1"], lower_bounds=["1", "0"], upper_bounds=["n", "m"], source_shape=["1:n", "0:m"], - metadata={"extent": {"first": "n", "second": "m"}}, + metadata={"representation": "m"}, ), ), ), + default_value="n + m", + metadata={"fortran_initializer": "m", "fortran_pointer_association": "n"}, ) ], - metadata={"shape": ["n", "m"]}, + metadata={"import_scope": "n"}, ) original = asdict(module) resolved = resolve_semantic_compile_time_values(module, {"n": n, "m": m}) assert asdict(module) == original - semantic_type = resolved.variables[0].semantic_type + variable = resolved.variables[0] + semantic_type = variable.semantic_type assert semantic_type.shape == [f"1:{n}", f"{m} + 1"] - assert semantic_type.constraints[0].arguments == [str(n), {"upper": str(m)}] - assert semantic_type.metadata == {"bounds": (str(n), [str(m)])} assert semantic_type.storage is not None - assert semantic_type.storage.metadata == {"extent": str(n)} assert semantic_type.storage.array is not None assert semantic_type.storage.array.shape == [f"1:{n}", f"{m} + 1"] assert semantic_type.storage.array.lower_bounds == ["1", "0"] assert semantic_type.storage.array.upper_bounds == [str(n), str(m)] assert semantic_type.storage.array.source_shape == [f"1:{n}", f"0:{m}"] - assert semantic_type.storage.array.metadata == {"extent": {"first": str(n), "second": str(m)}} + + # Declared expression fields are specialized; the default value is one too. + assert semantic_type.metadata["fortran_character_length"] == str(n) + assert variable.metadata["fortran_initializer"] == str(m) + assert variable.default_value == f"{n} + {m}" + + # Everything else is an opaque recorded decision, whatever it spells. + assert semantic_type.metadata["enum_name"] == "n" + assert semantic_type.constraints[0].arguments == ["n", {"upper": "m"}] + assert semantic_type.storage.metadata == {"address_role": "n"} + assert semantic_type.storage.array.metadata == {"representation": "m"} + assert variable.metadata["fortran_pointer_association"] == "n" + assert resolved.metadata == {"import_scope": "n"} + assert asdict(resolve_semantic_compile_time_values(resolved, {"n": n, "m": m})) == asdict(resolved) From 70bf45c55a49a1f4b983cca65b330324346a6912 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 17 Sep 2026 21:51:24 +0100 Subject: [PATCH 52/96] Give a character model one recorder for its selector A character declaration carries three facts only its selector can settle: the length, the kind, and whether the stored `kind` text is a length rather than a kind. The parser separated them, but any other producer of a parser model -- the type-mapping report, a test -- set the `kind` field alone and left the separated facts empty. Reading them then meant deciding again what that text spells, which is how `character(kind=c_char)` report rows silently lost their kind once semantics stopped falling back to the joined spelling. Record the selector once, through `record_character_selector` on both the declaration record and the variable model, so a model is never half-populated and no caller assigns a private attribute. `extract_character_selector` now returns that reading whole, carrying the length-syntax answer with the two expressions rather than leaving a second regex to ask it. A model built without a recorded selector states no length and no kind, which is what a bare `character` declaration means. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 7 +++ prik/parsers/fortran/models.py | 15 ++++++ prik/parsers/fortran/parser.py | 27 +++++----- prik/parsers/fortran/type_resolver.py | 28 +++++++++-- prik/pipeline/type_mapping_report.py | 17 ++++--- .../test_fortran_scalar_semantics.py | 9 +++- .../semantics/test_compile_time_values.py | 4 +- .../parsing/test_character_length_parsing.py | 49 +++++++++++++++++++ 8 files changed, 127 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30a8a56ee..547722711 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ release tags add a leading `v` to the package version. ## Unreleased +- A character parser model records its selector through + `FortranVariable.record_character_selector`, which reads the length, the + kind, and whether the stored text is a length in one place. A model built by + hand -- the type-mapping report's rows, a test -- now states the same facts a + parsed declaration does instead of leaving them to a second reader of the + joined `kind` text. + - Compile-time specialization reaches only the fields that hold declaration expressions. Every semantic metadata string was resolved as an expression, so a recorded decision spelling a parameter's name -- a pointer's diff --git a/prik/parsers/fortran/models.py b/prik/parsers/fortran/models.py index 596ab30ff..08d8460be 100644 --- a/prik/parsers/fortran/models.py +++ b/prik/parsers/fortran/models.py @@ -17,6 +17,7 @@ from dataclasses import dataclass, field from typing import Any +from prik.parsers.fortran.type_resolver import extract_character_selector from prik.utilities.declaration_expressions import split_dimension_bounds, split_top_level_expression @@ -269,6 +270,20 @@ def character_length_expression(self) -> str | None: """ return getattr(self, "_character_length_expression", None) + def record_character_selector(self, type_spec: str) -> None: + """Record what one character declaration's parenthesized selector states. + + This is the only supported way to give a character model its selector + facts, so every producer -- the parser, the type-mapping report, a test + -- reaches them through one reading of the source text. A model built + without it states no length and no kind, which is what a bare + ``character`` declaration means. + """ + selector = extract_character_selector(type_spec) + self._character_length_expression = selector.length + self._character_kind_expression = selector.kind + self._character_length_syntax = selector.length_syntax + @property def polymorphic(self) -> bool: """Whether this variable was declared with Fortran ``class(...)``.""" diff --git a/prik/parsers/fortran/parser.py b/prik/parsers/fortran/parser.py index b43cf8ab7..f67c9d853 100644 --- a/prik/parsers/fortran/parser.py +++ b/prik/parsers/fortran/parser.py @@ -371,6 +371,13 @@ class _Declaration: character_kind_expression: str | None = None declared_storage_bits: int | None = None + def record_character_selector(self, type_spec: str) -> None: + """Record what one character declaration's parenthesized selector states.""" + selector = extract_character_selector(type_spec) + self.character_length_expression = selector.length + self.character_kind_expression = selector.kind + self.character_length_syntax = selector.length_syntax + @dataclass class _ProcedureState: @@ -4288,15 +4295,9 @@ def _intrinsic_declaration(base_type: str, type_spec: str) -> _Declaration: extract_kind_from_type_spec(base_type, type_spec), ) if base_type == "character" and type_spec: - # The selector's two expressions are separated while the top-level - # items are known, so no later stage has to split them back apart. - length, kind = extract_character_selector(type_spec) - if length is not None: - declaration.character_length_expression = length - if kind is not None: - declaration.character_kind_expression = kind - if re.search(r"\bkind\s*=", type_spec, re.IGNORECASE) is None: - declaration.character_length_syntax = True + # The selector is read once here, while its top-level items are + # known, so no later stage has to split a joined spelling again. + declaration.record_character_selector(type_spec) return declaration @staticmethod @@ -4321,13 +4322,7 @@ def _apply_type_spelling_metadata(var: FortranVariable, spelling: str) -> None: if base_type in {"double precision", "double complex"}: var._target_kind_expression = "kind(1.0d0)" elif base_type == "character" and type_spec: - length, kind = extract_character_selector(type_spec) - if length is not None: - var._character_length_expression = length - if kind is not None: - var._character_kind_expression = kind - if re.search(r"\bkind\s*=", type_spec, re.IGNORECASE) is None: - var._character_length_syntax = True + var.record_character_selector(type_spec) @staticmethod def _apply_declaration_attributes( diff --git a/prik/parsers/fortran/type_resolver.py b/prik/parsers/fortran/type_resolver.py index 908d85adb..fb94443cc 100644 --- a/prik/parsers/fortran/type_resolver.py +++ b/prik/parsers/fortran/type_resolver.py @@ -8,6 +8,8 @@ from __future__ import annotations +from typing import NamedTuple + from prik.parsers.fortran.utils import split_csv @@ -49,7 +51,25 @@ def extract_kind_from_type_spec(base_type: str, type_spec: str) -> str | None: return None -def extract_character_selector(type_spec: str) -> tuple[str | None, str | None]: +class CharacterSelector(NamedTuple): + """What one character declaration's selector states. + + ``length_syntax`` records that the declaration's stored ``kind`` text is a + length rather than a kind, which is what ``character(8)``, ``character(*)`` + and ``character(len=n)`` all mean. It is read from the same split as the + two expressions, so a selector is interpreted once. + """ + + length: str | None = None + kind: str | None = None + + @property + def length_syntax(self) -> bool: + """Whether the selector names no kind, leaving its text a length.""" + return self.kind is None + + +def extract_character_selector(type_spec: str) -> CharacterSelector: """Return one character declaration's length and kind expressions. The selector carries two independent expressions, either of which may @@ -59,10 +79,10 @@ def extract_character_selector(type_spec: str) -> tuple[str | None, str | None]: and ``character(*)`` mean. """ if not type_spec: - return (None, None) + return CharacterSelector() inside = type_spec[1:-1].strip() if not inside: - return (None, None) + return CharacterSelector() length: str | None = None kind: str | None = None for item in split_csv(inside): @@ -75,7 +95,7 @@ def extract_character_selector(type_spec: str) -> tuple[str | None, str | None]: length = value.strip() or None elif keyword == "kind": kind = value.strip() or None - return (length, kind) + return CharacterSelector(length, kind) if __name__ == "__main__": diff --git a/prik/pipeline/type_mapping_report.py b/prik/pipeline/type_mapping_report.py index f5d9de7df..286ee3c08 100644 --- a/prik/pipeline/type_mapping_report.py +++ b/prik/pipeline/type_mapping_report.py @@ -84,19 +84,24 @@ def _fortran_type( kind: str | None = None, *, target_kind_expression: str | None = None, + character_selector: str | None = None, character_length_syntax: bool = False, declared_storage_bits: int | None = None, ) -> tuple[str, FortranVariable]: """Build one report-only Fortran variable and its displayed spelling. The helper records metadata that the existing Fortran converter consumes - when deriving a target type key. It returns the spelling and configured - variable without mutating any caller-owned object; the private attributes - intentionally distinguish legacy storage and character-length forms. + when deriving a target type key. A parenthesized character declaration + records its selector the way the parser does, so a report row states the + same length and kind a parsed declaration would; the legacy ``character*n`` + forms carry no selector and state length syntax directly. It returns the + spelling and configured variable without mutating any caller-owned object. """ variable = FortranVariable(name="value", base_type=base_type, kind=kind or "") if target_kind_expression: variable._target_kind_expression = target_kind_expression + if character_selector is not None: + variable.record_character_selector(character_selector) if character_length_syntax: variable._character_length_syntax = True if declared_storage_bits is not None: @@ -143,9 +148,9 @@ def _fortran_type( *(_fortran_type(f"logical(kind={kind})", "logical", kind) for kind in ("1", "2", "4", "8")), _fortran_type("logical(c_bool)", "logical", "c_bool"), _fortran_type("character", "character"), - _fortran_type("character(len=n)", "character", "n", character_length_syntax=True), - _fortran_type("character(kind=1)", "character", "kind=1"), - _fortran_type("character(kind=c_char)", "character", "kind=c_char"), + _fortran_type("character(len=n)", "character", "n", character_selector="(len=n)"), + _fortran_type("character(kind=1)", "character", "kind=1", character_selector="(kind=1)"), + _fortran_type("character(kind=c_char)", "character", "kind=c_char", character_selector="(kind=c_char)"), ) _FORTRAN_LEGACY_TYPES = ( 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 bed918970..920eb3cc2 100644 --- a/tests/fortran/data_types/semantics/test_fortran_scalar_semantics.py +++ b/tests/fortran/data_types/semantics/test_fortran_scalar_semantics.py @@ -14,6 +14,13 @@ from prik.parsers.fortran import parse_fortran_file as parse_fortran_source +def _character_variable(name: str, selector: str) -> FortranVariable: + """Build one character model stating the selector a declaration writes.""" + variable = FortranVariable(name=name, base_type="character", kind=selector[1:-1]) + variable.record_character_selector(selector) + return variable + + def test_intrinsic_builtin_kinds_map_to_semantic_types(): converter = FortranToIRConverter() cases = [ @@ -164,7 +171,7 @@ def test_fortran_storage_requirements_follow_resolved_kinds_and_actual_source_ty FortranVariable(name="default_real", base_type="real"), FortranVariable(name="selected", base_type="real", kind="rk"), FortranVariable(name="flag", base_type="logical", kind="8"), - FortranVariable(name="text", base_type="character", kind="len=12, kind=c_char"), + _character_variable("text", "(len=12, kind=c_char)"), ] ) diff --git a/tests/fortran/infrastructure/semantic_ir/semantics/test_compile_time_values.py b/tests/fortran/infrastructure/semantic_ir/semantics/test_compile_time_values.py index 719c1a3a7..113ad4469 100644 --- a/tests/fortran/infrastructure/semantic_ir/semantics/test_compile_time_values.py +++ b/tests/fortran/infrastructure/semantic_ir/semantics/test_compile_time_values.py @@ -352,9 +352,9 @@ def test_semantic_compile_time_requirements_cover_all_parser_contexts(): ) == [] ) - # A character selector's kind is a parser fact separate from its length. + # A character model states its selector the way every producer records it. bad_character = FortranVariable(name="bad_character", base_type="character", kind="bad") - bad_character._character_kind_expression = "bad" + bad_character.record_character_selector("(kind=bad)") unsupported = collect_semantic_compile_time_requirements( FortranFile( variables=[ diff --git a/tests/fortran/strings/parsing/test_character_length_parsing.py b/tests/fortran/strings/parsing/test_character_length_parsing.py index dbef8de95..9d74e8834 100644 --- a/tests/fortran/strings/parsing/test_character_length_parsing.py +++ b/tests/fortran/strings/parsing/test_character_length_parsing.py @@ -1,6 +1,7 @@ """Declaration parsing, interfaces, and less common scope edges.""" from prik.parsers.fortran import parse_fortran_file +from prik.parsers.fortran.models import FortranVariable def test_character_entity_lengths_and_assumed_bounds_are_preserved(): @@ -20,3 +21,51 @@ def test_character_entity_lengths_and_assumed_bounds_are_preserved(): assert args["table"].shape == ["0:"] assert args["table"].lbound == ["0"] assert args["table"].ubound == [None] + + +def test_a_character_selector_separates_a_comma_bearing_kind_from_its_length(): + """Either selector expression may hold commas, so both are read whole.""" + parsed = parse_fortran_file( + """\ +module selector_mod + use iso_c_binding, only : c_char + implicit none + character(len=8, kind=max(c_char, 1)) :: spread_out +end module selector_mod +""", + filename="selector.f90", + ) + declared = parsed.modules[0].variables[0] + + assert declared.character_length_expression == "8" + assert declared.character_kind_expression == "max(c_char, 1)" + assert declared.character_length_syntax is False + + +def test_a_character_model_states_only_the_selector_it_records(): + """The `kind` text alone cannot say whether it spells a length or a kind. + + Every producer of a character model records its selector through one + reader, so a model built without one states neither -- which is what a + bare `character` declaration means -- rather than leaving a second reader + to guess from the joined text. + """ + bare = FortranVariable(name="text", base_type="character", kind="c_char") + + assert bare.character_length_expression is None + assert bare.character_kind_expression is None + assert bare.character_length_syntax is False + + named = FortranVariable(name="text", base_type="character", kind="len=12, kind=c_char") + named.record_character_selector("(len=12, kind=c_char)") + + assert named.character_length_expression == "12" + assert named.character_kind_expression == "c_char" + assert named.character_length_syntax is False + + positional = FortranVariable(name="text", base_type="character", kind="8") + positional.record_character_selector("(8)") + + assert positional.character_length_expression == "8" + assert positional.character_kind_expression is None + assert positional.character_length_syntax is True From 31936b1b432a4da4c6060dcd98d0006117a0b431 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 17 Sep 2026 21:51:45 +0100 Subject: [PATCH 53/96] Accept a character literal that states its kind Fortran may write a character literal's kind before its opening quote, as `c_char_'abc'` and `1_'abc'` do. PRIK recognized a literal by matching any text between two quotes, which that spelling does not start with, so character(kind=c_char, len=3), parameter :: tagged = c_char_'abc' recorded no value and was refused as an unsupported module variable, although the same declaration with a plain literal builds. Read the optional kind prefix where the literal is already decoded, returning the characters alone -- the kind is a declared type fact, not part of the value -- and have the parser classify a literal through that one reader instead of its own pattern. The two answers now agree: a kind-prefixed constant reaches both the generated contract and the built extension, and `'a' // 'b'` is recorded as the expression policy already treated it as, rather than as a literal. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 8 ++++++++ prik/parsers/fortran/parser.py | 6 +++++- prik/utilities/declaration_expressions.py | 13 ++++++++++--- .../end_to_end/test_character_constant_quoting.py | 14 ++++++++++++++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 547722711..ac2676458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ release tags add a leading `v` to the package version. ## Unreleased +- A character constant may state its kind before the opening quote, so + `character(kind=c_char, len=3), parameter :: tagged = c_char_'abc'` is + published and returned as `abc`. The kind-prefixed spelling was not + recognized as a literal at all, leaving the parameter with no value and the + build refusing it as an unsupported module variable. One reader now decides + what a whole character literal is, so `'a' // 'b'` is recorded as the + expression it is rather than as a literal. + - A character parser model records its selector through `FortranVariable.record_character_selector`, which reads the length, the kind, and whether the stored text is a length in one place. A model built by diff --git a/prik/parsers/fortran/parser.py b/prik/parsers/fortran/parser.py index f67c9d853..7454c1b8b 100644 --- a/prik/parsers/fortran/parser.py +++ b/prik/parsers/fortran/parser.py @@ -20,6 +20,7 @@ from prik.utilities.declaration_expressions import ( declaration_expression_identifiers, evaluate_integer_expression, + fortran_character_value, split_declaration_assignment, split_dimension_bounds, split_top_level_expression, @@ -5585,7 +5586,10 @@ def _is_literal_parameter_value(value: str) -> bool: return True if re.fullmatch(r"\.(?:true|false)\.", text, re.IGNORECASE): return True - if re.fullmatch(r"(['\"]).*\1", text): + # One reader decides what a whole character literal is, so the value a + # parameter records is the one later stages decode. Matching any text + # between two quotes also accepted `'a' // 'b'`, which is an expression. + if fortran_character_value(text) is not None: return True if text.startswith("[") and text.endswith("]"): return all(FortranParser._is_literal_parameter_value(part) for part in split_csv(text[1:-1])) diff --git a/prik/utilities/declaration_expressions.py b/prik/utilities/declaration_expressions.py index 104198d04..5ba15584e 100644 --- a/prik/utilities/declaration_expressions.py +++ b/prik/utilities/declaration_expressions.py @@ -452,16 +452,23 @@ def declaration_expression_identifiers(expression: str) -> tuple[str, ...]: return tuple(dict.fromkeys(names)) +#: A character literal's optional kind, written before its opening quote. +_CHARACTER_KIND_PREFIX = re.compile(r"^(?:[A-Za-z]\w*|\d+)_(?=[\"'])") + + def fortran_character_value(text: str) -> str | None: """Return the value of one whole Fortran character literal, or ``None``. Fortran doubles a quote to hold one, so ``'don''t'`` is five characters. Python reads that same spelling as two literals written side by side and joins them, losing the quote, so a Fortran literal is decoded here rather - than handed to a Python reader. Text that is not one whole literal returns - ``None`` for the caller to read as an expression. + than handed to a Python reader. A literal may also state its kind before + the opening quote, as ``c_char_'abc'`` does; the kind is a declared type + fact rather than part of the value, so only the characters are returned. + Text that is not one whole literal returns ``None`` for the caller to read + as an expression. """ - stripped = text.strip() + stripped = _CHARACTER_KIND_PREFIX.sub("", text.strip(), count=1) if len(stripped) < 2 or stripped[0] != stripped[-1] or stripped[0] not in "\"'": return None quote = stripped[0] diff --git a/tests/fortran/strings/end_to_end/test_character_constant_quoting.py b/tests/fortran/strings/end_to_end/test_character_constant_quoting.py index 0b4a35768..d0a14cd2a 100644 --- a/tests/fortran/strings/end_to_end/test_character_constant_quoting.py +++ b/tests/fortran/strings/end_to_end/test_character_constant_quoting.py @@ -18,10 +18,14 @@ SOURCE = """\ module quoting_mod + use iso_c_binding, only : c_char implicit none character(len=5), parameter :: word = 'don''t' character(len=3), parameter :: pair = "a""b" character(len=4), parameter :: plain = 'abcd' + character(kind=c_char, len=3), parameter :: tagged = c_char_'abc' + character(len=3), parameter :: numbered = 1_'xyz' + character(len=5), parameter :: tagged_quote = c_char_'don''t' end module quoting_mod """ @@ -43,6 +47,13 @@ def test_a_doubled_quote_reaches_python_as_one_quote(built): assert built.quoting_mod.plain == "abcd" +def test_a_literal_states_its_kind_without_the_kind_joining_the_value(built): + """A literal's kind is a type fact, so only its characters are the value.""" + assert built.quoting_mod.tagged == "abc" + assert built.quoting_mod.numbered == "xyz" + assert built.quoting_mod.tagged_quote == "don't" + + def test_a_generated_contract_states_the_declared_characters(tmp_path: Path): """The contract publishes the same value the extension returns.""" source = tmp_path / "quoting.f90" @@ -55,3 +66,6 @@ def test_a_generated_contract_states_the_declared_characters(tmp_path: Path): assert 'word: Final[String[5]] = "don\'t"' in contract assert "pair: Final[String[3]] = 'a\"b'" in contract assert "plain: Final[String[4]] = 'abcd'" in contract + assert "tagged: Final[String[3]] = 'abc'" in contract + assert "numbered: Final[String[3]] = 'xyz'" in contract + assert 'tagged_quote: Final[String[5]] = "don\'t"' in contract From 33364eaad662ebe4b087d6f2bed935b7666043a0 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 17 Sep 2026 22:38:23 +0100 Subject: [PATCH 54/96] Read a character kind from the selector before the legacy field `record_character_selector` states a character declaration's kind, but semantic conversion still tested the legacy `kind` field first: raw_kind = var.target_kind_expression or var.kind if not raw_kind: return None A model built through the recorder alone leaves that field empty, so a declaration plainly recording `kind=bad` was reported as the default character kind -- the recorded fact was authoritative in name only. The compile-time requirement collector gated on the same field, so such a model also skipped its unsupported-kind diagnostic. Branch on `character` before consulting the field in `_semantic_kind_key` and `_target_type_key`, and gate the collector on the kind each model actually records. The collector now also reports the kind itself rather than the joined selector text, so `character(len=8, kind=bad)` names `bad` instead of `len=8, kind=bad`. The migrated callers populated both fields, so no parsed-source build changed; this makes the recorder sufficient on its own, as its contract says. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 4 +- prik/semantics/fortran2ir.py | 43 +++++++++++++------ .../parsing/test_character_length_parsing.py | 26 ++++++++++- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac2676458..f083dab78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,9 @@ release tags add a leading `v` to the package version. kind, and whether the stored text is a length in one place. A model built by hand -- the type-mapping report's rows, a test -- now states the same facts a parsed declaration does instead of leaving them to a second reader of the - joined `kind` text. + joined `kind` text. Semantic conversion reads that recorded selector as the + authority for a character kind, so a model carrying only the selector is not + reported as the default character kind. - Compile-time specialization reaches only the fields that hold declaration expressions. Every semantic metadata string was resolved as an expression, diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 29a486718..9ce542730 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -2406,13 +2406,13 @@ def _semantic_type_name(self, var: FortranVariable) -> str: def _semantic_kind_key(self, var: FortranVariable) -> str | None: """Normalize the declaration's kind text for semantic type-map lookup.""" - raw_kind = var.target_kind_expression or var.kind - if not raw_kind: - return None - base_type = var.base_type.lower() if base_type == "character": return self._character_kind_expression(var) + + raw_kind = var.target_kind_expression or var.kind + if not raw_kind: + return None kind = self._resolve_compile_time_text(str(raw_kind)).strip().lower() if base_type == "logical": return "c_bool" if kind == "c_bool" else kind @@ -2428,22 +2428,24 @@ def _target_type_key(self, var: FortranVariable) -> tuple[str, str | None]: storage key, while explicit character kind clauses retain their kind. """ base_type = var.base_type.lower() + if base_type == "character": + return base_type, self._character_kind_expression(var) + raw_kind = var.target_kind_expression or var.kind if not raw_kind: return base_type, None - - if base_type == "character": - return base_type, self._character_kind_expression(var) kind = self._resolve_compile_time_text(str(raw_kind)).strip().lower() return base_type, kind def _character_kind_expression(self, var: FortranVariable) -> str | None: """Return the kind a character declaration states, or ``None`` for the default. - The parser separates the selector's kind from its length, so the kind - is read from that fact rather than found again inside a joined - spelling, where an expression holding a comma of its own -- a - ``kind=max(c_char, 1)`` -- would be cut short. + A character model records its selector apart from the legacy ``kind`` + field, which carries a length for some spellings and nothing at all for + a model built through ``record_character_selector``. The recorded fact + is therefore the authority, read before that field is consulted and in + place of searching a joined spelling, where an expression holding a + comma of its own -- a ``kind=max(c_char, 1)`` -- would be cut short. """ declared = getattr(var, "character_kind_expression", None) if not declared: @@ -4175,6 +4177,20 @@ def collect_fortran_type_storage_requirements( return requirements +def _declared_kind_expression(var: FortranVariable) -> str | None: + """Return the kind text one declaration states, as its model records it. + + A character model records its selector apart from the legacy ``kind`` + field, which carries a length for some spellings and nothing at all for a + model built through ``record_character_selector``, so the character kind is + read from that recorded fact. Every other base type states its kind in the + field itself. + """ + if str(var.base_type or "").lower() == "character": + return var.character_kind_expression + return var.kind or None + + def collect_semantic_compile_time_requirements( parsed, *, @@ -4238,11 +4254,12 @@ def add_requirement( add_requirement("parameter_value", ctx, expression=expression) base_type = parameter_base_type - if base_type not in {"integer", "real", "complex", "logical", "character"} or not var.kind: + declared_kind = _declared_kind_expression(var) + if base_type not in {"integer", "real", "complex", "logical", "character"} or not declared_kind: continue kind_key = converter._semantic_kind_key(var) if converter.type_map.get((base_type, kind_key)) is None: - expression = _resolve_compile_time_text(str(var.kind), values) + expression = _resolve_compile_time_text(str(declared_kind), values) add_requirement( "unsupported_kind", ctx, diff --git a/tests/fortran/strings/parsing/test_character_length_parsing.py b/tests/fortran/strings/parsing/test_character_length_parsing.py index 9d74e8834..a9d011bcc 100644 --- a/tests/fortran/strings/parsing/test_character_length_parsing.py +++ b/tests/fortran/strings/parsing/test_character_length_parsing.py @@ -1,7 +1,8 @@ """Declaration parsing, interfaces, and less common scope edges.""" from prik.parsers.fortran import parse_fortran_file -from prik.parsers.fortran.models import FortranVariable +from prik.parsers.fortran.models import FortranFile, FortranVariable +from prik.semantics.fortran2ir import FortranToIRConverter, collect_semantic_compile_time_requirements def test_character_entity_lengths_and_assumed_bounds_are_preserved(): @@ -69,3 +70,26 @@ def test_a_character_model_states_only_the_selector_it_records(): assert positional.character_length_expression == "8" assert positional.character_kind_expression is None assert positional.character_length_syntax is True + + +def test_a_recorded_selector_is_what_semantics_reads_for_a_character_kind(): + """The recorded selector is the authority, not the legacy `kind` field. + + A model built through the recorder alone leaves that field empty, so + consulting it first would report the default character kind while the + model plainly states another one. + """ + converter = FortranToIRConverter() + + recorded = FortranVariable(name="x", base_type="character") + recorded.record_character_selector("(kind=c_char)") + + assert recorded.kind == "" + assert converter._semantic_kind_key(recorded) == "c_char" + assert converter._target_type_key(recorded) == ("character", "c_char") + + unsupported = FortranVariable(name="x", base_type="character") + unsupported.record_character_selector("(kind=bad)") + requirements = collect_semantic_compile_time_requirements(FortranFile(variables=[unsupported])) + + assert [(item["symbol"], item["kind"], item["expression"]) for item in requirements] == [("x", "bad", "bad")] From e717f0bdf3bc60f9207caa5bd143414cc425122f Mon Sep 17 00:00:00 2001 From: said Date: Thu, 17 Sep 2026 23:18:58 +0100 Subject: [PATCH 55/96] Rename argument references without reaching inside literals `SemanticFunction.__eq__` compares two procedures by shape rather than by argument naming, rewriting each argument's name to a positional placeholder so `f(n, x)` with `x(n)` matches `f(m, y)` with `y(m)`. The rewrite ran over whole expression strings, character literals included, so a string default spelling an argument's own name was rewritten too: report(n, label='n') == report(m, label='m') -> True Both defaults canonicalized to `'__arg_0__'`, making two procedures that default to different text compare equal. Apply the rename through `outside_character_literals`, the same reader that keeps lexical translation out of a literal's contents. A literal states characters, not a reference to an argument, so it is compared as written; shape expressions and other references canonicalize exactly as before. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- prik/semantics/models.py | 20 +++++++-- ...an_conversion_procedures_and_interfaces.py | 43 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/prik/semantics/models.py b/prik/semantics/models.py index 232e2d510..ef8e4292b 100644 --- a/prik/semantics/models.py +++ b/prik/semantics/models.py @@ -14,6 +14,8 @@ from dataclasses import dataclass, field from typing import Any +from prik.utilities.declaration_expressions import outside_character_literals + EXTERNAL_TYPE_REF_METADATA = "external_type_ref" PROTOTYPE_REF_METADATA = "prototype_ref" @@ -612,12 +614,22 @@ def _canonical_expression(value: Any, name_map: dict[str, str]) -> Any: def _canonical_expression_text(text: str, name_map: dict[str, str]) -> str: + """Rename argument references so two procedures compare by shape, not naming. + + A character literal's contents are its value, not a reference to anything, + so renaming stops at the quotes: two procedures whose string defaults spell + their own argument names -- ``f(n, label='n')`` and ``f(m, label='m')`` -- + default to different text and must not compare equal. + """ if not name_map: return text - result = text - for name, placeholder in name_map.items(): - result = re.sub(rf"\b{re.escape(name)}\b", placeholder, result) - return result + + def renamed(chunk: str) -> str: + for name, placeholder in name_map.items(): + chunk = re.sub(rf"\b{re.escape(name)}\b", placeholder, chunk) + return chunk + + return outside_character_literals(text, renamed) # ============================================================ diff --git a/tests/fortran/functions/semantics/test_fortran_conversion_procedures_and_interfaces.py b/tests/fortran/functions/semantics/test_fortran_conversion_procedures_and_interfaces.py index 2c5a5f326..84379cd5a 100644 --- a/tests/fortran/functions/semantics/test_fortran_conversion_procedures_and_interfaces.py +++ b/tests/fortran/functions/semantics/test_fortran_conversion_procedures_and_interfaces.py @@ -177,3 +177,46 @@ def test_semantic_function_projection_equality_and_placeholders(): ) assert left == right + + +def test_semantic_function_equality_renames_references_not_literal_contents(): + """Argument renaming compares shape; a string default states characters. + + Two procedures whose string defaults happen to spell their own argument + names default to different text, so canonicalizing the reference must stop + at the quotes. + """ + + def report(extent: str, default_value: str) -> SemanticFunction: + return SemanticFunction( + name="report", + native_name="report", + arguments=[ + SemanticArgument(extent, SemanticType("Int32", dtype="Int32")), + SemanticArgument( + "label", + SemanticType("String", dtype="String"), + default_value=default_value, + ), + ], + ) + + assert report("n", "'n'") != report("m", "'m'") + # Renaming still makes two identically shaped procedures compare equal. + assert report("n", "'fixed'") == report("m", "'fixed'") + + +def test_semantic_function_equality_still_canonicalizes_shape_references(): + """A shape naming an argument compares by position, not by that name.""" + + def scale(extent: str, array: str) -> SemanticFunction: + return SemanticFunction( + name="scale", + native_name="scale", + arguments=[ + SemanticArgument(extent, SemanticType("Int32", dtype="Int32")), + SemanticArgument(array, SemanticType("Float64", dtype="Float64", rank=1, shape=[f"1:{extent}"])), + ], + ) + + assert scale("n", "x") == scale("m", "y") From c2b4770828ca1877fbc37ecd5263d03a9be81daf Mon Sep 17 00:00:00 2001 From: said Date: Thu, 17 Sep 2026 23:19:14 +0100 Subject: [PATCH 56/96] Break a continued Fortran line at the expression's own commas The continuation renderer re-parsed an already-rendered expression by splitting on every comma, which does not know what a comma belongs to. Inside a character literal that changes the program: Fortran resumes a continued literal after the next line's `&`, so build_message(prefix, 'alpha, beta', suffix) emitted `'alpha, &` / ` & beta'`, which gfortran accepts and reads as `alpha, beta` -- two spaces where the source states one, with no diagnostic. Split at the expression's own delimiters with `split_top_level_expression`, which already skips brackets and quoted literals. A literal stays one item, so no break lands inside it, and a nested call keeps its arguments together instead of being cut at an inner comma. Today's generated bridges carry no such literal -- source character constants reach the C wrapper, not the Fortran one -- so no emitted source changes. The renderer no longer depends on that remaining true. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 6 ++++ prik/printers/fortran.py | 22 +++++++++------ .../printers/test_source_printers.py | 28 +++++++++++++++++++ 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f083dab78..5c99138e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ release tags add a leading `v` to the package version. ## Unreleased +- Generated Fortran continuation never breaks a line inside a character + literal. A long call was split at every comma, so an argument such as + `'alpha, beta'` continued mid-literal and compiled to `alpha, beta` -- a + different string, with no diagnostic. Lines now break at the call's own + arguments, which also keeps a nested call whole. + - A character constant may state its kind before the opening quote, so `character(kind=c_char, len=3), parameter :: tagged = c_char_'abc'` is published and returned as `abc`. The kind-prefixed spelling was not diff --git a/prik/printers/fortran.py b/prik/printers/fortran.py index 592739d82..99779206f 100644 --- a/prik/printers/fortran.py +++ b/prik/printers/fortran.py @@ -11,6 +11,7 @@ import textwrap +from prik.utilities.declaration_expressions import split_top_level_expression from prik.codegen.nodes import ( FortranAllocate, FortranAssignment, @@ -593,17 +594,19 @@ def _continued_item_ending(self, last_item: bool, last_argument: bool, suffix: s return suffix if last_argument else ", &" def _array_constructor_items(self, expression: str) -> tuple[str, ...] | None: - """Parse a simple bracketed constructor into item text, or return None. + """Parse a bracketed constructor into item text, or return None. - This intentionally recognizes only the shallow layout form used by the - continuation renderer; nested semantic expression parsing belongs earlier. + Items are separated at the constructor's own commas, so a nested call + or a character literal holding a comma stays one item. Breaking a line + inside a literal would change the characters it states; this helper + performs layout only, and nested semantic parsing belongs earlier. """ if not (expression.startswith("[") and expression.endswith("]")): return None content = expression[1:-1] if not content: return None - return tuple(item.strip() for item in content.split(",")) + return tuple(item.strip() for item in split_top_level_expression(content, ",")) def _parenthesized_items( self, @@ -611,15 +614,18 @@ def _parenthesized_items( *, minimum_items: int = 2, ) -> tuple[str, tuple[str, ...]] | None: - """Parse one shallow parenthesized value into its name and item texts. + """Parse one parenthesized value into its name and item texts. - The optional minimum keeps callers from expanding short forms. Unmatched, - nameless, or too-short expressions return None and remain opaque source. + Items are separated at this value's own commas, so a nested call or a + character literal holding a comma stays one item and no continuation + lands inside it. The optional minimum keeps callers from expanding short + forms. Unmatched, nameless, or too-short expressions return None and + remain opaque source. """ opening = expression.find("(") if opening < 1 or not expression.endswith(")"): return None - items = tuple(item.strip() for item in expression[opening + 1 : -1].split(",")) + items = tuple(item.strip() for item in split_top_level_expression(expression[opening + 1 : -1], ",")) if len(items) < minimum_items: return None return expression[:opening], items diff --git a/tests/fortran/infrastructure/printers/test_source_printers.py b/tests/fortran/infrastructure/printers/test_source_printers.py index 003f9b68f..71584fd12 100644 --- a/tests/fortran/infrastructure/printers/test_source_printers.py +++ b/tests/fortran/infrastructure/printers/test_source_printers.py @@ -157,6 +157,34 @@ def test_fortran_source_printer_wraps_long_parenthesized_call_arguments(): assert max(map(len, source.splitlines())) <= 124 +def test_fortran_source_printer_never_continues_inside_a_character_literal(): + """A literal's commas are its characters, so no continuation may split it. + + Fortran resumes a continued literal after the next line's `&`, so a break + placed at a comma inside quotes changes the characters the literal states + while still compiling. + """ + padding = "x" * 40 + expression = f"build_message(prefix_{padding}, 'alpha, beta', suffix_{padding})" + + source = FortranSourcePrinter().doprint(FortranAssignment("destination", CodeExpression(expression))) + + assert "'alpha, beta'" in source + assert "'alpha, &" not in source + assert max(map(len, source.splitlines())) <= 132 + + +def test_fortran_source_printer_breaks_a_call_at_its_own_arguments(): + """A nested call's commas belong to it, so the outer break skips them.""" + padding = "y" * 40 + expression = f"compute_total(first_{padding}, max(second_term, third_term), fourth_{padding})" + + source = FortranSourcePrinter().doprint(FortranAssignment("destination", CodeExpression(expression))) + + assert "& max(second_term, third_term), &" in source + assert max(map(len, source.splitlines())) <= 132 + + def test_fortran_source_printer_wraps_long_pointer_array_sections(): slices = ", ".join(f"1:values_upper_bound_{axis} + 1:values_stride_{axis}" for axis in range(4)) From f626c32e378de4135b767ab61c6abc587c76d0e0 Mon Sep 17 00:00:00 2001 From: said Date: Fri, 18 Sep 2026 00:24:31 +0100 Subject: [PATCH 57/96] Publish a prototype or generic only where the module declares it Three declarations reached a module's Python surface without its accessibility being consulted. A procedure-local interface block was promoted to a module prototype: `_module_prototypes()` walked `module.interfaces` rather than the module's own blocks, and its `seen` set was keyed on the bare name. Two contained procedures each declaring `abstract interface ... subroutine cb` therefore collapsed into one prototype, and whichever won typed both -- a contract stating `integer` for a callback the source declares `real`. Callback resolution already read these per scope; the published identity did not. A block written inside a procedure now carries its owning scope, takes the scope-qualified contract name that scope alone can reach, and is private, because a `use` of the module cannot name it. A private prototype and a private generic were written into `__all__`. `SemanticPrototype` already recorded its visibility and the printer ignored it; `ProcedureOverloadSet` had no visibility at all, so export policy read every generic as public through `getattr(declaration, "visibility", "public")`. The overload set now records the accessibility its module gives the generic name, and publication reads it. Both still appear in the contract body, which is what annotations and dispatch need, and neither is exported. Naming the dispatcher and emitting its definition asked the allocator as two different owners, which only a settled public name hid; both now reserve under one identity. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 10 ++ prik/printers/pyi.py | 18 ++- prik/semantics/fortran2ir.py | 74 ++++++++-- prik/semantics/models.py | 8 ++ .../fcallback_scalar_f90.pyi | 6 +- .../test_fortran_callback_semantics.py | 5 +- .../scope_name_reuse_combinations.json | 3 +- .../semantics/test_declaration_publication.py | 126 ++++++++++++++++++ 8 files changed, 231 insertions(+), 19 deletions(-) create mode 100644 tests/fortran/modules/semantics/test_declaration_publication.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c99138e3..ebd13bb81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ release tags add a leading `v` to the package version. ## Unreleased +- A generated contract publishes a prototype or a generic only where the + module makes it reachable. A `private` abstract interface and a `private` + generic were written into `__all__` although the module keeps both to + itself, and an interface block written inside a contained procedure was + promoted to a module publication -- so two procedures each declaring + `abstract interface ... cb` shared one prototype, and the second was given + the first's signature. Such a block now takes its own scope-qualified + contract name, stays out of `__all__`, and each procedure's callback is + typed by the interface its own scope declares. + - Generated Fortran continuation never breaks a line inside a character literal. A long call was split at every comma, so an argument such as `'alpha, beta'` continued mid-literal and compiled to `alpha, beta` -- a diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index a36ae9d04..421f6b35d 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -288,6 +288,8 @@ def published_names(self, module: SemanticModule) -> dict[str, str]: self._visit(module, context) names = dict(context.published_names) for prototype in module.prototypes: + if self._is_private(prototype): + continue names[str(prototype.name)] = str(prototype.name) for reexport in module.reexports: if reexport.entity_kind == "prototype" and reexport.publishes_to_python(): @@ -758,14 +760,21 @@ def _module_exported_names( for semantic_class in self._contract_items(module.classes): if not self._is_private(semantic_class): names.append(self._class_name(semantic_class, context)) - names.extend(str(prototype.name) for prototype in module.prototypes) + # A prototype the contract needs for typing is not thereby published: + # a private one names a signature the module keeps to itself, and the + # annotations referring to it still resolve inside this file. + names.extend(str(prototype.name) for prototype in module.prototypes if not self._is_private(prototype)) for variable in self._contract_items(module.variables): if getattr(variable, "visibility", "public") != "private": names.append(self._module_variable_name(variable, context)) for function in self._contract_items(module.functions, keep_names=overload_targets): if not self._is_private(function): names.append(self._callable_name(function, context)) - names.extend(self._overload_set_name(overload_set, context) for overload_set in module.overload_sets) + names.extend( + self._overload_set_name(overload_set, context) + for overload_set in module.overload_sets + if not self._is_private(overload_set) + ) for reexport in module.reexports: if not reexport.publishes_to_python(): continue @@ -2496,7 +2505,10 @@ def _overload_set_name(overload_set: ProcedureOverloadSet, context: _PyiEmission settled = context.settled("function", overload_set.name) if settled is not None: return context.publish(overload_set.name, settled) - return context.public_name(overload_set.name, category="function", owner=overload_set) + # The dispatcher and the name written for it are one declaration, so + # both reserve under the identity the emission uses. Asking as two + # owners would hand the definition a second, deduplicated spelling. + return context.public_name(overload_set.name, category="function", owner=("overload", overload_set.name)) @staticmethod def _data_member_name( diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 9ce542730..6504617c0 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -193,6 +193,9 @@ class _CallbackInterface: local_name: str | None = None """Spelling the importing scope binds, when a ``use`` renamed the interface.""" + declaring_scope: tuple[str, ...] = () + """Contained procedure declaring the block, empty for a module's own block.""" + @property def native_name(self) -> str: """Return the name the declaring module gives this interface.""" @@ -203,6 +206,19 @@ def visible_name(self) -> str: """Return the canonical spelling visible where the interface was resolved.""" return self.local_name or self.signature.name + @property + def contract_name(self) -> str: + """Return the spelling a contract writes for this interface. + + Two contained procedures may each declare an interface of the same + name meaning different signatures, so a procedure-local block is named + for the scope that owns it. A module's own block keeps its name, which + is the one another module imports. + """ + if not self.declaring_scope: + return self.visible_name + return "_".join((*self.declaring_scope, self.visible_name)) + @dataclass(frozen=True) class _DerivedTypeContext: @@ -806,14 +822,30 @@ def _scope_callback_interfaces( which is why the enclosing module contributes only its own blocks. """ visible = dict(base) + scope = (str(scope_name),) if scope_name else () for interface in cls._procedure_interfaces(owner, scope_name): for signature in interface.procedures: - visible[signature.name.casefold()] = _CallbackInterface(signature, owner) + visible[signature.name.casefold()] = _CallbackInterface(signature, owner, declaring_scope=scope) if interface.name and len(interface.procedures) == 1: - visible[interface.name.casefold()] = _CallbackInterface(interface.procedures[0], owner) + visible[interface.name.casefold()] = _CallbackInterface( + interface.procedures[0], + owner, + declaring_scope=scope, + ) cls._merge_imported_callback_interfaces(visible, modules, uses, seen=frozenset(), override=True) return visible + @staticmethod + def _interface_declaring_scope(interface) -> tuple[str, ...]: + """Return the contained procedure declaring one interface block, if any. + + A module's own block returns the empty scope, which is what makes its + name reachable through a ``use`` of the module. + """ + if str(getattr(interface, "declaring_scope_kind", "module")).casefold() != "procedure": + return () + return tuple(str(part) for part in getattr(interface, "declaring_scope_path", ()))[-1:] + @staticmethod def _procedure_interfaces(owner: FortranModule | None, scope_name: str | None): """Return the interface blocks written inside one contained procedure.""" @@ -951,7 +983,7 @@ def _callback_semantic_type( # 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 + local_name = resolved.contract_name if resolved is not None else interface_name return SemanticType( local_name, dtype="Prototype", @@ -1096,13 +1128,26 @@ def _module_prototypes( referenced: set[str], called: set[str], ) -> list[SemanticPrototype]: - """Convert every referenced interface into one exact prototype signature.""" + """Convert every referenced interface into one exact prototype signature. + + A block written inside a contained procedure declares a signature that + procedure alone can name, and two procedures may spell different + signatures the same way. Such a block therefore takes its own + scope-qualified contract identity and stays private: the contract needs + it to annotate that procedure's callback, but a ``use`` of this module + cannot reach it, so the module does not publish it. + """ prototypes: list[SemanticPrototype] = [] seen: set[str] = set() for interface in module.interfaces: + scope = self._interface_declaring_scope(interface) for signature in interface.procedures: - name = interface.name if interface.name and len(interface.procedures) == 1 else signature.name - if not (interface.abstract or name.casefold() in referenced or name.casefold() in called): + declared = interface.name if interface.name and len(interface.procedures) == 1 else signature.name + name = "_".join((*scope, declared)) + # A callback argument records the contract identity, which for + # a procedure-local block is the scope-qualified one; a shape + # calling the name still spells it as the source declares it. + if not (interface.abstract or name.casefold() in referenced or declared.casefold() in called): continue if name in seen: continue @@ -1119,14 +1164,14 @@ def _module_prototypes( prototypes.append( SemanticPrototype( name=name, - native_name=name, + native_name=declared, arguments=arguments, return_type=return_type, metadata=self._procedure_metadata(signature), - visibility=self._symbol_visibility(module, name), + visibility="private" if scope else self._symbol_visibility(module, declared), origin=SemanticOrigin( source_language="fortran", - native_name=name, + native_name=declared, native_abi=self._procedure_native_abi(signature), native_symbol=self._procedure_native_symbol(signature), native_scope=module.name, @@ -3001,7 +3046,12 @@ def _module_overload_sets( ) if missing or not procedures: if self._is_procedure_generic_name(interface.name): - overload_sets.append(ProcedureOverloadSet(interface.name)) + overload_sets.append( + ProcedureOverloadSet( + interface.name, + visibility=self._symbol_visibility(module, interface.name), + ) + ) continue if self._is_procedure_generic_name(interface.name): constructor_class = class_map.get(interface.name.casefold()) @@ -3026,6 +3076,7 @@ def _module_overload_sets( native_scope=str(module.origin.native_name or module.name) if hasattr(module, "origin") else module.name, + visibility=self._symbol_visibility(module, interface.name), ) target_lookup = procedure_lookup | inline_lookup | inherited_lookup for target_name, candidate in zip(target_names, overload_set.procedures, strict=True): @@ -3152,6 +3203,7 @@ def _normal_overload_set( procedures: list[SemanticFunction], *, native_scope: str | None = None, + visibility: str = "public", ) -> ProcedureOverloadSet: """Copy regular generic candidates and attach generic dispatch metadata. @@ -3184,7 +3236,7 @@ def _normal_overload_set( candidate.metadata[OVERLOAD_KIND_METADATA] = "generic" candidate.metadata[OVERLOAD_TARGET_METADATA] = candidate.native_name or candidate.name candidates.append(candidate) - return ProcedureOverloadSet(name, candidates, native_scope=native_scope) + return ProcedureOverloadSet(name, candidates, native_scope=native_scope, visibility=visibility) def _defined_overload_sets( self, diff --git a/prik/semantics/models.py b/prik/semantics/models.py index ef8e4292b..8a2231886 100644 --- a/prik/semantics/models.py +++ b/prik/semantics/models.py @@ -396,6 +396,14 @@ class ProcedureOverloadSet: native_scope: str | None = None """Module declaring the generic, which need not own every specific.""" + visibility: str = "public" + """Accessibility the declaring module gives the generic name itself. + + A generic follows its module's accessibility like any other declaration, so + a `private` one names a dispatcher the module keeps to itself. Publication + reads this rather than assuming a generic is public. + """ + FORTRAN_GENERIC_NAME_METADATA = "fortran_generic_name" OVERLOAD_KIND_METADATA = "overload_kind" diff --git a/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_scalar_f90/fcallback_scalar_f90.pyi b/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_scalar_f90/fcallback_scalar_f90.pyi index 5d088e9e7..87be5f006 100644 --- a/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_scalar_f90/fcallback_scalar_f90.pyi +++ b/tests/fortran/callbacks/end_to_end/fixtures/contracts/fcallback_scalar_f90/fcallback_scalar_f90.pyi @@ -11,7 +11,7 @@ def notify_callback( ) -> None: ... @prototype -def callback( +def apply_explicit_callback( value: In(Addr(Float64)) ) -> Float64: ... @@ -23,7 +23,7 @@ def apply_scalar( @native_call([Arg(0), Addr(Arg(1))]) def apply_explicit( - callback: callback, + callback: apply_explicit_callback, value: Float64 ) -> Float64: ... @@ -33,4 +33,4 @@ def call_notify( value: Float64 ) -> None: ... -__all__ = ["scalar_callback", "notify_callback", "callback", "apply_scalar", "apply_explicit", "call_notify"] +__all__ = ["scalar_callback", "notify_callback", "apply_scalar", "apply_explicit", "call_notify"] diff --git a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py index 3df05bff3..c744e94bc 100644 --- a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py +++ b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py @@ -95,7 +95,10 @@ def test_dummy_procedure_interfaces_become_complete_callable_contracts(): ) explicit_callback = get_function(module, "explicit_case").arguments[0].semantic_type - assert explicit_callback.name == "callback" + # A block written inside a procedure names a signature only that procedure + # can reach, so its contract identity is qualified by the owning scope. + assert explicit_callback.name == "explicit_case_callback" + assert explicit_callback.metadata["prototype_ref"]["name"] == "callback" assert [argument.name for argument in explicit_callback.metadata["arguments"]] == ["Int32"] assert explicit_callback.metadata["return"].name == "Int32" 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 bfbbe2c10..d73ae644b 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 @@ -1475,7 +1475,8 @@ "return_type": null, "visibility": "public" } - ] + ], + "visibility": "public" } ], "prototypes": [], diff --git a/tests/fortran/modules/semantics/test_declaration_publication.py b/tests/fortran/modules/semantics/test_declaration_publication.py new file mode 100644 index 000000000..3bf7478c7 --- /dev/null +++ b/tests/fortran/modules/semantics/test_declaration_publication.py @@ -0,0 +1,126 @@ +"""A module publishes the declarations its own accessibility makes reachable. + +A prototype or a generic follows the same rule every other declaration does: +the contract may need to name it for typing or dispatch, but only what the +module makes public becomes part of its Python surface. A block written inside +a contained procedure is reachable in that procedure alone, so it is never a +module publication at all. +""" + +from pathlib import Path + +from prik.parsers.fortran import parse_fortran_file +from prik.printers.pyi import PyiPrinter +from prik.policy.exports import complete_python_export_policy +from prik.semantics.fortran2ir import fortran_file_to_semantic_modules + +PRIVATE_SOURCE = """\ +module m + implicit none + private + + abstract interface + subroutine cb() + end subroutine + end interface + + interface hidden_generic + module procedure hidden_one + end interface + + public :: run +contains + subroutine run(f) + procedure(cb) :: f + call f() + end subroutine run + + subroutine hidden_one(a) + integer, intent(in) :: a + print *, a + end subroutine hidden_one +end module m +""" + +LOCAL_INTERFACE_SOURCE = """\ +module m + implicit none +contains + subroutine first(f) + abstract interface + subroutine cb(x) + integer :: x + end subroutine + end interface + procedure(cb) :: f + call f(1) + end subroutine first + + subroutine second(f) + abstract interface + subroutine cb(x) + real :: x + end subroutine + end interface + procedure(cb) :: f + call f(1.0) + end subroutine second +end module m +""" + + +def _module(source: str, tmp_path: Path): + """Convert one source to its policy-complete semantic module.""" + path = tmp_path / "m.f90" + path.write_text(source, encoding="utf-8") + module = fortran_file_to_semantic_modules(parse_fortran_file(source, filename=str(path)))[0] + complete_python_export_policy(module) + return module + + +def test_a_private_prototype_and_generic_state_their_accessibility(tmp_path: Path): + """Semantics records what the module's `private` default says about each.""" + module = _module(PRIVATE_SOURCE, tmp_path) + + assert [(item.name, item.visibility) for item in module.prototypes] == [("cb", "private")] + assert [(item.name, item.visibility) for item in module.overload_sets] == [("hidden_generic", "private")] + + +def test_a_private_prototype_and_generic_are_written_but_not_published(tmp_path: Path): + """The contract names both for typing and dispatch, and publishes neither.""" + module = _module(PRIVATE_SOURCE, tmp_path) + contract = PyiPrinter().emit(module) + + # `run` annotates its callback with the prototype, so the name must exist. + assert "def cb() -> None: ..." in contract + assert "def hidden_generic(" in contract + assert '__all__ = ["run"]' in contract + assert "cb" not in PyiPrinter().published_names(module) + + +def test_two_procedures_may_name_different_interfaces_the_same_way(tmp_path: Path): + """A block inside a procedure is that procedure's, so each keeps its own.""" + module = _module(LOCAL_INTERFACE_SOURCE, tmp_path) + + assert [(item.name, item.native_name, item.visibility) for item in module.prototypes] == [ + ("first_cb", "cb", "private"), + ("second_cb", "cb", "private"), + ] + # Each procedure's callback keeps the signature its own block declares. + signatures = { + function.name: [argument.semantic_type.metadata["arguments"][0].name for argument in function.arguments] + for function in module.functions + } + assert signatures == {"first": ["Int32"], "second": ["Float32"]} + + +def test_a_procedure_local_interface_is_never_a_module_publication(tmp_path: Path): + """A `use` of the module cannot reach it, so the contract does not publish it.""" + module = _module(LOCAL_INTERFACE_SOURCE, tmp_path) + contract = PyiPrinter().emit(module) + + assert "def first_cb(" in contract + assert "def second_cb(" in contract + assert "f: first_cb" in contract + assert "f: second_cb" in contract + assert '__all__ = ["first", "second"]' in contract From 79f35042d5b5365ba1ae1f7c9303475f958645bc Mon Sep 17 00:00:00 2001 From: said Date: Fri, 18 Sep 2026 00:26:01 +0100 Subject: [PATCH 58/96] Decide a generic re-export's publication where publication is decided `complete_reexport_publication_policy()` set `python_exported = True` for a generic reached through a second namespace, and the printer then refused to write it, holding its own `_UNPUBLISHABLE_REEXPORT_KINDS`. The completed policy therefore said one thing and the emitted contract another, and any other reader of `python_exported` would have believed the first. A generic is reachable like any other name; what it lacks is a single object a second namespace can bind, which makes publishing it a publication decision rather than an accessibility one. Settle it in the policy that owns `python_exported`, and let the printer read the answer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- prik/policy/exports.py | 15 +++++++++++++++ prik/printers/pyi.py | 9 --------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/prik/policy/exports.py b/prik/policy/exports.py index e0cea2c2a..3ef5eee48 100644 --- a/prik/policy/exports.py +++ b/prik/policy/exports.py @@ -69,6 +69,13 @@ def complete_python_export_policy( _complete_reexport_names(module, naming, contract_named=contract_named) +#: Entity kinds a second namespace cannot publish, whatever it may reach. +#: +#: A generic dispatcher has no single object another namespace can bind, so it +#: is published where it is declared and nowhere else. +UNPUBLISHABLE_REEXPORT_KINDS = frozenset({"generic"}) + + def complete_reexport_publication_policy( module: models.SemanticModule, *, @@ -81,12 +88,20 @@ def complete_reexport_publication_policy( ``public`` statement names them. A loaded contract has already stated its export surface, so every re-export record constructed from that surface is published. + + A generic is reachable through the importing module like any other name, + but it dispatches rather than naming one object, so PRIK publishes it in + its declaring namespace alone. That is a publication decision, settled here + once, rather than an accessibility one. """ if contract_named is None: contract_named = bool(module.metadata.get(PYI_LOADED_METADATA)) for reexport in module.reexports: if reexport.python_exported is not None: continue + if reexport.entity_kind in UNPUBLISHABLE_REEXPORT_KINDS: + reexport.python_exported = False + continue reexport.python_exported = bool( contract_named or not reexport.declaration_dependency or reexport.explicitly_public ) diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index 421f6b35d..f55709cba 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -219,11 +219,6 @@ def published_name(published: dict[str, str] | None, source: object) -> str | No return matches[0] if len(matches) == 1 else None -# Publication of these kinds has no runtime form yet, so a generated contract -# does not claim it. -_UNPUBLISHABLE_REEXPORT_KINDS = frozenset({"generic"}) - - class PyiPrinter(ClassVisitor): """Emit editable Python stub text from semantic IR models. @@ -780,10 +775,6 @@ def _module_exported_names( continue if self._is_source_kind_import(str(reexport.origin_module)): continue - if reexport.entity_kind in _UNPUBLISHABLE_REEXPORT_KINDS: - # A generic dispatcher has no single object another namespace - # can bind, so a source build does not publish it here. - continue # A prototype keeps its declared spelling wherever it is written, so # the name published for it is the one its import binds. local = str(reexport.local_name) From aabd16d61eddfe3e7f3c06ce22782333bb7f1217 Mon Sep 17 00:00:00 2001 From: said Date: Fri, 18 Sep 2026 00:27:20 +0100 Subject: [PATCH 59/96] Apply the same resolution rule at every re-export hop `_module_reexports()` judges a direct import properly: it asks whether the name is accessible, collects every route carrying it, and refuses to name an entity when the routes disagree. `_resolve_reexport_origin()` then followed the chain by a different rule -- after checking the intermediate module's own declarations it walked that module's explicit `use` mappings and returned on the first match, asking neither question again. So a module that imports `x` and declares `private :: x` still handed the declaration behind it to whoever imported `x` from it, and a module reaching two different `x` -- which correctly refuses to resolve them for itself -- handed on whichever route came first. Each hop now collects the routes carrying the name, requires the module's Fortran accessibility to keep it public there, and resolves only when those routes name one entity. Accessibility is the Fortran rule, not Python publication, because a dependency-only name stays semantically reachable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 7 ++ prik/semantics/fortran2ir.py | 38 ++++--- .../semantics/test_reexport_accessibility.py | 102 ++++++++++++++++++ 3 files changed, 135 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebd13bb81..208413174 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ release tags add a leading `v` to the package version. ## Unreleased +- Following a name through an intermediate module applies that module's own + accessibility. A module importing `x` and declaring `private :: x` no longer + passes a route to the declaration behind it, and a module reaching two + different `x` no longer resolves to whichever route was read first. Both + cases are now reported unresolved, as a direct import of two disagreeing + routes already was. + - A generated contract publishes a prototype or a generic only where the module makes it reachable. A `private` abstract interface and a `private` generic were written into `__all__` although the module keeps both to diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 6504617c0..3a8061ce4 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -1893,6 +1893,14 @@ def _resolve_reexport_origin( the entity. Following the chain reports the declaration itself: its kind, the module holding it, and the name it is declared under. A name reached through no declaration, or through a cycle, stays unknown. + + Each hop applies the rule a direct import does. A module that does not + declare the name has it only through its own ``use`` statements, so the + chain continues only while Fortran accessibility keeps the name public + there -- a `private` statement in an intermediate module ends it -- and + only while every route through that module names one entity. Two routes + naming different declarations leave the origin genuinely ambiguous + there, exactly as they would in the importing module. """ key = (module_name.casefold(), source_name.casefold()) declaring = index.get(module_name.casefold()) @@ -1902,20 +1910,26 @@ def _resolve_reexport_origin( if kind != "unknown": return kind, declaring.name, source_name seen = seen | {key} - for used_name, mappings in declaring.uses.items(): - for mapping in mappings: - if mapping.local_name.casefold() == source_name.casefold(): - return cls._resolve_reexport_origin(index, used_name, mapping.source, seen) - # A `use` naming no list carries every public name of what it reads. - resolved = [ - origin + named = [ + (used_name, mapping.source) for used_name, mappings in declaring.uses.items() - if not mappings - for origin in (cls._resolve_reexport_origin(index, used_name, source_name, seen),) - if origin[0] != "unknown" + for mapping in mappings + if mapping.local_name.casefold() == source_name.casefold() ] - if len(resolved) == 1: - return resolved[0] + # A `use` naming no list carries every public name of what it reads. + wildcard = [(used_name, source_name) for used_name, mappings in declaring.uses.items() if not mappings] + routes = named or wildcard + route_names = tuple(dict.fromkeys(used_name for used_name, _source in routes)) + if not routes or not cls._effective_accessibility(declaring)(source_name, route_names): + return "unknown", module_name, source_name + origins = {cls._resolve_reexport_origin(index, used_name, name, seen) for used_name, name in routes} + if named: + # A named route states the entity it carries, so an unreadable one + # beside a resolved one still means the name reaches two things. + return next(iter(origins)) if len(origins) == 1 else ("unknown", module_name, source_name) + known = [origin for origin in origins if origin[0] != "unknown"] + if len(known) == 1: + return known[0] return "unknown", module_name, source_name @classmethod diff --git a/tests/fortran/modules/semantics/test_reexport_accessibility.py b/tests/fortran/modules/semantics/test_reexport_accessibility.py index 1cff1c3f3..e2fa4a28e 100644 --- a/tests/fortran/modules/semantics/test_reexport_accessibility.py +++ b/tests/fortran/modules/semantics/test_reexport_accessibility.py @@ -612,3 +612,105 @@ def test_a_compile_time_symbol_is_not_substituted_inside_a_character_literal(): # A reference outside the literal is still resolved. assert _resolve_compile_time_text("runtime + 1", values) == "4 + 1" assert _resolve_compile_time_text('len("runtime") + runtime', values) == 'len("runtime") + 4' + + +TRANSITIVE_DECLARING = """\ +module a_mod + implicit none + integer :: x = 1 +end module a_mod +""" + +TRANSITIVE_OTHER = """\ +module c_mod + implicit none + real :: x = 2.0 +end module c_mod +""" + + +def _project_modules(tmp_path: Path, *sources: str): + """Parse one throwaway project and return its semantic modules by name.""" + (tmp_path / "project.f90").write_text("\n".join(sources), encoding="utf-8") + modules = fortran_project_to_semantic_modules(parse_fortran_project(str(tmp_path))) + return {module.name: module for module in modules} + + +def test_a_private_name_in_an_intermediate_module_ends_the_chain(tmp_path: Path): + """Each hop applies the accessibility rule, so a `private` stops the walk. + + `middle` imports `x` and makes it private, so `outer` cannot reach the + declaration behind it however `middle` got there. + """ + modules = _project_modules( + tmp_path, + TRANSITIVE_DECLARING, + """\ +module middle_mod + use a_mod, only : x + implicit none + private :: x +end module middle_mod + +module outer_mod + use middle_mod, only : x + implicit none +end module outer_mod +""", + ) + + reexports = {item.local_name: item for item in modules["outer_mod"].reexports} + assert reexports["x"].entity_kind == "unknown" + assert reexports["x"].origin_module == "middle_mod" + + +def test_routes_disagreeing_inside_an_intermediate_module_stay_unresolved(tmp_path: Path): + """`middle` reaches two different `x`, so no hop through it names one.""" + modules = _project_modules( + tmp_path, + TRANSITIVE_DECLARING, + TRANSITIVE_OTHER, + """\ +module middle_mod + use a_mod, only : x + use c_mod, only : x + implicit none +end module middle_mod + +module outer_mod + use middle_mod, only : x + implicit none +end module outer_mod +""", + ) + + reexports = {item.local_name: item for item in modules["outer_mod"].reexports} + assert reexports["x"].entity_kind == "unknown" + assert reexports["x"].origin_module == "middle_mod" + + +def test_an_ordinary_chain_still_reaches_the_declaring_module(tmp_path: Path): + """One accessible, unambiguous route per hop resolves to the declaration.""" + modules = _project_modules( + tmp_path, + TRANSITIVE_DECLARING, + """\ +module middle_mod + use a_mod, only : x + implicit none + public :: x +end module middle_mod + +module outer_mod + use middle_mod, only : x + implicit none +end module outer_mod +""", + ) + + reexports = {item.local_name: item for item in modules["outer_mod"].reexports} + assert (reexports["x"].entity_kind, reexports["x"].origin_module, reexports["x"].source_name) == ( + "variable", + "a_mod", + "x", + ) From c0561ac4ed4fd957b0ece0108c29dc248f9b3f2e Mon Sep 17 00:00:00 2001 From: said Date: Fri, 18 Sep 2026 00:28:15 +0100 Subject: [PATCH 60/96] Carry an enum's enumerators as the constants they already are PRIK models a Fortran enumerator as a constant `SemanticVariable`, but the accessibility and re-export inventory never looked at `module.enums`. `_module_declared_names()` listed procedures, derived types, variables and interfaces, so a plain `use` of a module declaring `enumerator :: red = 1` carried nothing for `red`. `_declared_entity_kind()` likewise did not recognize one, so `use colors, only : red` produced a re-export of kind `unknown` -- which a contract may still publish while the module-variable publication machinery, which attaches a second namespace only to a re-export classified `variable`, passes it by. Read enumerators wherever this layer reads a module's variables, as the variables they become. `_module_declaration_dependencies()` reads their initializers too, so an enumerator whose value names an imported constant records that constant as a declaration dependency rather than a publication. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 8 +++ prik/semantics/fortran2ir.py | 23 ++++++++ .../semantics/test_reexport_accessibility.py | 58 +++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 208413174..18fb493ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ release tags add a leading `v` to the package version. ## Unreleased +- An enum's enumerators are carried by `use` like the constants they are. A + plain `use` of a module declaring `enumerator :: red = 1` carried nothing for + `red`, and naming it in an `only` list produced a re-export of unknown kind, + which the module-variable publication machinery does not attach. Enumerators + are now read as variables wherever this layer reads a module's declarations, + including as declaration dependencies when an enumerator's value names an + imported constant. + - Following a name through an intermediate module applies that module's own accessibility. A module importing `x` and declaring `private :: x` no longer passes a route to the declaration behind it, and a module reaching two diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 3a8061ce4..3bfbab995 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -1694,6 +1694,18 @@ def _module_interfaces(module: FortranModule): if str(getattr(interface, "declaring_scope_kind", "module")).casefold() == "module" ) + @staticmethod + def _module_enumerators(module: FortranModule): + """Return every enumerator one module's enum blocks declare. + + An enumerator is a named constant the module declares, and PRIK models + it as one: a ``use`` carries it exactly as it carries a ``parameter``, + so this layer reads it wherever it reads the module's variables. + """ + return tuple( + enumerator for enum in getattr(module, "enums", ()) for enumerator in getattr(enum, "enumerators", ()) + ) + @staticmethod def _module_declared_names(module: FortranModule) -> set[str]: """Return the names declared by one module for accessibility resolution. @@ -1703,11 +1715,14 @@ def _module_declared_names(module: FortranModule) -> set[str]: it, which is what another module imports to write a ``procedure(...)`` declaration. A specific inside an ordinary generic is not separately declared here, because the generic is the name that block introduces. + An enumerator is a declared constant, so it is named here as a variable + is, which is what it becomes. """ return { *(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", ())), + *(enumerator.name.casefold() for enumerator in FortranToIRConverter._module_enumerators(module)), *( interface.name.casefold() for interface in FortranToIRConverter._module_interfaces(module) @@ -1779,6 +1794,10 @@ def add_procedure(procedure: FortranProcedureSignature) -> None: for interface in module.interfaces: for procedure in interface.procedures: add_procedure(procedure) + for enumerator in cls._module_enumerators(module): + declaration_text.extend( + str(value) for value in (enumerator.symbolic_value, enumerator.value) if value is not None + ) return { identifier.casefold() @@ -2009,6 +2028,10 @@ def _declared_entity_kind(declaring: FortranModule | None, source_name: str) -> return "derived_type" if any(variable.name.casefold() == key for variable in getattr(declaring, "variables", ())): return "variable" + # An enumerator is a named constant, which is the representation it + # already has downstream, so a route reaching one names a variable. + if any(enumerator.name.casefold() == key for enumerator in FortranToIRConverter._module_enumerators(declaring)): + return "variable" return "unknown" @staticmethod diff --git a/tests/fortran/modules/semantics/test_reexport_accessibility.py b/tests/fortran/modules/semantics/test_reexport_accessibility.py index e2fa4a28e..758e724b3 100644 --- a/tests/fortran/modules/semantics/test_reexport_accessibility.py +++ b/tests/fortran/modules/semantics/test_reexport_accessibility.py @@ -714,3 +714,61 @@ def test_an_ordinary_chain_still_reaches_the_declaring_module(tmp_path: Path): "a_mod", "x", ) + + +def test_an_enumerator_is_carried_and_classified_as_the_constant_it_is(tmp_path: Path): + """An enum names constants, which is how every later stage models them.""" + modules = _project_modules( + tmp_path, + """\ +module colors_mod + implicit none + enum, bind(c) + enumerator :: red = 1 + enumerator :: green = 2 + end enum +end module colors_mod + +module facade_mod + use colors_mod + implicit none +end module facade_mod + +module named_facade_mod + use colors_mod, only : red + implicit none +end module named_facade_mod +""", + ) + + # A plain `use` carries every public name, enumerators included. + carried = {item.local_name: item.entity_kind for item in modules["facade_mod"].reexports} + assert carried == {"red": "variable", "green": "variable"} + + named = {item.local_name: item for item in modules["named_facade_mod"].reexports} + assert named["red"].entity_kind == "variable" + assert (named["red"].origin_module, named["red"].source_name) == ("colors_mod", "red") + + +def test_an_enumerator_initializer_is_a_declaration_dependency(tmp_path: Path): + """A name an enum's value reads expresses a declaration, so it is a dependency.""" + modules = _project_modules( + tmp_path, + """\ +module constants_mod + implicit none + integer, parameter :: base = 10 +end module constants_mod + +module colors_mod + use constants_mod, only : base + implicit none + enum, bind(c) + enumerator :: red = base + end enum +end module colors_mod +""", + ) + + reexports = {item.local_name: item for item in modules["colors_mod"].reexports} + assert reexports["base"].declaration_dependency is True From 31f8262210cfc22f46fb88011890d0941b62279f Mon Sep 17 00:00:00 2001 From: said Date: Fri, 18 Sep 2026 05:36:11 +0100 Subject: [PATCH 61/96] Weigh every route to a name, however each one entered Route resolution kept named and wildcard imports apart. `_module_reexports()` recorded the names a named mapping carried and `_wildcard_reexports()` then skipped exactly those, while `_resolve_reexport_origin()` chose `named or wildcard` -- so a named route hid the wildcard routes to the same local name instead of being weighed against them. A module writing use a_mod, only : x use c_mod where both offer `x` therefore published `a_mod::x` as the canonical owner. That is not a harmless preference: a re-exported module variable generates native access to the owner PRIK chose, so the ambiguity never reaches the Fortran compiler to be diagnosed. Collect the routes once, in `_name_routes()`, and let `_module_reexports()`, its wildcard half, and `_resolve_reexport_origin()` all read that one collection. How a route entered says nothing about what it carries: a named mapping states its name, and a plain `use` carries a name when the module it reads is parsed and offers it. An unparsed plain `use` cannot be enumerated and so is no route at all, which keeps PRIK from refusing resolutions it can make. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 9 + prik/semantics/fortran2ir.py | 206 +++++++++--------- .../semantics/test_reexport_accessibility.py | 91 ++++++++ 3 files changed, 198 insertions(+), 108 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18fb493ee..179ce8360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ release tags add a leading `v` to the package version. ## Unreleased +- A use-associated name is resolved from every route carrying it, whichever + way each route entered. A module writing `use a_mod, only : x` beside a plain + `use c_mod` that also offers `x` reaches two different entities, and the + named route was examined first and published `a_mod::x` as the canonical + owner -- which a re-exported module variable then generates native access to + directly, so the Fortran compiler never diagnoses the ambiguity. Routes that + name one entity still resolve, and a plain `use` of a module PRIK never read + carries no assumed name. + - An enum's enumerators are carried by `use` like the constants they are. A plain `use` of a module declaring `enumerator :: red = 1` carried nothing for `red`, and naming it in an `only` list produced a re-export of unknown kind, diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 3bfbab995..2884d289a 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -16,6 +16,7 @@ from __future__ import annotations from collections.abc import Iterable +from typing import NamedTuple from copy import deepcopy from dataclasses import dataclass, replace import re @@ -220,6 +221,13 @@ def contract_name(self) -> str: return "_".join((*self.declaring_scope, self.visible_name)) +class _NameRoute(NamedTuple): + """One way a module reaches a name: the module used, and the name there.""" + + used_module: str + source_name: str + + @dataclass(frozen=True) class _DerivedTypeContext: """Keep lexical derived-type lookup facts while one parser node is converted. @@ -1831,6 +1839,80 @@ def _module_public_names( offered.setdefault(name, set()).add(module_name) return {name for name, routes in offered.items() if is_public(name, routes)} + @staticmethod + def _reconcile_routes(origins: list[tuple[str, str, str]]) -> tuple[str, str, str] | None: + """Return the one entity a local name's routes reach, or ``None``. + + An ordinary entity has one declaration, so routes that disagree -- or a + readable route standing beside one this project never parsed -- mean the + local name reaches more than one thing, and choosing between them would + be a guess rather than a reading. + + """ + distinct = list(dict.fromkeys(origins)) + if len(distinct) == 1: + return distinct[0] + return None + + @classmethod + def _name_routes( + cls, + module: FortranModule, + index: dict[str, FortranModule], + local_name: str, + ) -> tuple[_NameRoute, ...]: + """Return every route by which one module reaches one local name. + + A named mapping states the name it carries. A plain ``use`` carries + every public name of what it reads, so it is a route for this name only + when that module is parsed and offers it -- an unparsed module cannot be + enumerated, and assuming it carries the name would refuse resolutions + PRIK can make. How a route entered, through ``only`` or through a plain + ``use``, says nothing about what it carries, so both kinds are collected + together and weighed the same way afterwards. + """ + folded = local_name.casefold() + routes: list[_NameRoute] = [ + _NameRoute(used_name, mapping.source) + for used_name, mappings in module.uses.items() + for mapping in mappings + if mapping.local_name.casefold() == folded + ] + for used_name, mappings in module.uses.items(): + if mappings: + continue + used = index.get(used_name.casefold()) + if used is not None and folded in cls._module_public_names(used, index): + routes.append(_NameRoute(used_name, local_name)) + return tuple(routes) + + @classmethod + def _use_associated_names( + cls, + module: FortranModule, + index: dict[str, FortranModule], + ) -> tuple[str, ...]: + """Return every local name one module reaches through ``use``, in order. + + A named mapping contributes the spelling it binds; a plain ``use`` + contributes the names the module it reads offers, which are known only + for a parsed one. Named spellings come first, so a name reached both + ways keeps the case its ``use`` statement wrote. + """ + names: dict[str, str] = {} + for mappings in module.uses.values(): + for mapping in mappings: + names.setdefault(mapping.local_name.casefold(), mapping.local_name) + for used_name, mappings in module.uses.items(): + if mappings: + continue + used = index.get(used_name.casefold()) + if used is None: + continue + for name in sorted(cls._module_public_names(used, index)): + names.setdefault(name, name) + return tuple(names.values()) + def _module_reexports( cls, module: FortranModule, @@ -1850,29 +1932,18 @@ def _module_reexports( explicit_public = {str(name).casefold() for name in module.public_symbols} index = module_index or {} reexports: list[SemanticReexport] = [] - named: set[str] = set() - named_mappings: dict[str, list[tuple[str, FortranUseMapping]]] = {} - for module_name, mappings in module.uses.items(): - for mapping in mappings: - named_mappings.setdefault(mapping.local_name.casefold(), []).append((module_name, mapping)) - for local_key, routes in named_mappings.items(): - named.add(local_key) - local_name = routes[0][1].local_name - route_names = tuple(dict.fromkeys(module_name for module_name, _mapping in routes)) - if local_key in declared or not is_public(local_name, route_names): + for local_name in cls._use_associated_names(module, index): + local_key = local_name.casefold() + routes = cls._name_routes(module, index, local_name) + route_names = tuple(dict.fromkeys(route.used_module for route in routes)) + if local_key in declared or not routes or not is_public(local_name, route_names): continue - origins = { - cls._resolve_reexport_origin(index, module_name, mapping.source) for module_name, mapping in routes - } - # Every route has to name one entity. Routes that all resolve the - # same way name it; a single unresolved route still names whatever - # the ``use`` reached. Where routes disagree, or a resolved route - # sits beside one this project cannot read, the name means more - # than one thing here and choosing the readable one would be a - # guess about the module that was never parsed. - if len(origins) > 1: + origin = cls._reconcile_routes( + [cls._resolve_reexport_origin(index, route.used_module, route.source_name) for route in routes] + ) + if origin is None: continue - kind, origin_module, origin_name = next(iter(origins)) + kind, origin_module, origin_name = origin reexports.append( SemanticReexport( local_name, @@ -1885,16 +1956,6 @@ def _module_reexports( explicitly_public=local_key in explicit_public, ) ) - reexports.extend( - cls._wildcard_reexports( - module, - index, - declared=declared, - dependencies=dependencies, - explicit_public=explicit_public, - named=named, - ) - ) return reexports @classmethod @@ -1929,85 +1990,14 @@ def _resolve_reexport_origin( if kind != "unknown": return kind, declaring.name, source_name seen = seen | {key} - named = [ - (used_name, mapping.source) - for used_name, mappings in declaring.uses.items() - for mapping in mappings - if mapping.local_name.casefold() == source_name.casefold() - ] - # A `use` naming no list carries every public name of what it reads. - wildcard = [(used_name, source_name) for used_name, mappings in declaring.uses.items() if not mappings] - routes = named or wildcard - route_names = tuple(dict.fromkeys(used_name for used_name, _source in routes)) + routes = cls._name_routes(declaring, index, source_name) + route_names = tuple(dict.fromkeys(route.used_module for route in routes)) if not routes or not cls._effective_accessibility(declaring)(source_name, route_names): return "unknown", module_name, source_name - origins = {cls._resolve_reexport_origin(index, used_name, name, seen) for used_name, name in routes} - if named: - # A named route states the entity it carries, so an unreadable one - # beside a resolved one still means the name reaches two things. - return next(iter(origins)) if len(origins) == 1 else ("unknown", module_name, source_name) - known = [origin for origin in origins if origin[0] != "unknown"] - if len(known) == 1: - return known[0] - return "unknown", module_name, source_name - - @classmethod - def _wildcard_reexports( - cls, - module: FortranModule, - index: dict[str, FortranModule], - *, - declared: set[str], - dependencies: set[str], - explicit_public: set[str], - named: set[str], - ) -> list[SemanticReexport]: - """Return the names a plain ``use`` carried into this module and it publishes. - - A ``use`` naming no list carries every public name of the module it - reads, and this module's effective accessibility then decides which of - those it publishes in turn. A carried name 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 [] - is_public = cls._effective_accessibility(module) - carried: dict[str, list[FortranModule]] = {} - for used in wildcard: - for name in cls._module_public_names(used, index): - carried.setdefault(name, []).append(used) - reexports: list[SemanticReexport] = [] - for name, routes in sorted(carried.items()): - route_names = tuple(dict.fromkeys(used.name for used in routes)) - if name in declared or name in named or not is_public(name, route_names): - continue - # Every route has to name one entity, the way a named import does. - # An unresolved route is kept in the comparison rather than - # discarded: dropping it would leave a readable route standing - # alone and answer for a module this project never read. - origins = {cls._resolve_reexport_origin(index, used.name, name) for used in routes} - if len(origins) != 1: - continue - kind, origin_module, origin_name = next(iter(origins)) - reexports.append( - SemanticReexport( - name, - origin_module, - origin_name, - module.name, - entity_kind=kind, - access_modules=list(route_names), - declaration_dependency=name in dependencies, - explicitly_public=name in explicit_public, - ) - ) - return reexports + origin = cls._reconcile_routes( + [cls._resolve_reexport_origin(index, route.used_module, route.source_name, seen) for route in routes] + ) + return origin if origin is not None else ("unknown", module_name, source_name) @staticmethod def _declared_entity_kind(declaring: FortranModule | None, source_name: str) -> str: diff --git a/tests/fortran/modules/semantics/test_reexport_accessibility.py b/tests/fortran/modules/semantics/test_reexport_accessibility.py index 758e724b3..b1cc0228e 100644 --- a/tests/fortran/modules/semantics/test_reexport_accessibility.py +++ b/tests/fortran/modules/semantics/test_reexport_accessibility.py @@ -772,3 +772,94 @@ def test_an_enumerator_initializer_is_a_declaration_dependency(tmp_path: Path): reexports = {item.local_name: item for item in modules["colors_mod"].reexports} assert reexports["base"].declaration_dependency is True + + +def test_a_named_and_a_wildcard_route_to_different_entities_stay_unresolved(tmp_path: Path): + """How a route entered says nothing about what it carries. + + `b_mod` reaches two different `x`, one through an `only` list and one + through a plain `use`. Examining the named route first would publish + `a_mod::x` as the canonical one, and a re-exported module variable + generates native access to that owner directly, so the Fortran compiler + never gets to diagnose the ambiguity. + """ + modules = _project_modules( + tmp_path, + TRANSITIVE_DECLARING, + TRANSITIVE_OTHER, + """\ +module b_mod + use a_mod, only : x + use c_mod + implicit none +end module b_mod +""", + ) + + assert [item.local_name for item in modules["b_mod"].reexports] == [] + + +def test_a_named_and_a_wildcard_route_to_one_entity_resolve_together(tmp_path: Path): + """Two routes naming one declaration are not a disagreement.""" + modules = _project_modules( + tmp_path, + TRANSITIVE_DECLARING, + """\ +module pass_mod + use a_mod + implicit none +end module pass_mod + +module b_mod + use a_mod, only : x + use pass_mod + implicit none +end module b_mod +""", + ) + + reexports = {item.local_name: item for item in modules["b_mod"].reexports} + assert (reexports["x"].entity_kind, reexports["x"].origin_module) == ("variable", "a_mod") + + +def test_an_unparsed_plain_use_carries_no_assumed_name(tmp_path: Path): + """PRIK cannot enumerate an unread module, so it is not a route for a name.""" + modules = _project_modules( + tmp_path, + TRANSITIVE_DECLARING, + """\ +module b_mod + use a_mod, only : x + use external_mod + implicit none +end module b_mod +""", + ) + + reexports = {item.local_name: item for item in modules["b_mod"].reexports} + assert (reexports["x"].entity_kind, reexports["x"].origin_module) == ("variable", "a_mod") + + +def test_mixed_routes_through_an_intermediate_module_stay_unresolved(tmp_path: Path): + """The rule is the same at every hop, whichever way each route entered.""" + modules = _project_modules( + tmp_path, + TRANSITIVE_DECLARING, + TRANSITIVE_OTHER, + """\ +module middle_mod + use a_mod, only : x + use c_mod + implicit none +end module middle_mod + +module outer_mod + use middle_mod, only : x + implicit none +end module outer_mod +""", + ) + + reexports = {item.local_name: item for item in modules["outer_mod"].reexports} + assert reexports["x"].entity_kind == "unknown" + assert reexports["x"].origin_module == "middle_mod" From 9cbe581e2634002fd191003f0949202e74d97aa9 Mon Sep 17 00:00:00 2001 From: said Date: Fri, 18 Sep 2026 05:38:12 +0100 Subject: [PATCH 62/96] Assemble a generic from every interface contributing to it An ordinary entity has one declaration, so two routes naming different ones leave a local name ambiguous. A generic is the exception the language makes: accessible generic interfaces sharing an identifier all contribute their specific procedures to one generic. Applying the ordinary rule to them was wrong in both directions. `_imported_generic_interface()` returned the first imported generic it found, so a module importing `convert` from two modules that each declare one kept that module's specifics and silently dropped the other's. And because route resolution refused a name reached by two different origins, importing both without also declaring one locally dropped `convert` altogether. Gather the contributors instead. `_imported_generic_interfaces()` follows every route carrying the name, in source order, walks through a module that re-exports rather than declares, and applies Fortran accessibility at each hop, so a route made private contributes nothing. One declaration reached by several routes contributes once, and a block written inside a contained procedure is that procedure's and never a module's. Route reconciliation treats generic origins as contributors rather than rivals; the merged specifics live in the overload set assembled from them, not in a single pretended identity. A generic is still published only by the namespace declaring it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 9 + prik/semantics/fortran2ir.py | 119 +++++---- .../test_generic_contributor_merging.py | 236 ++++++++++++++++++ 3 files changed, 322 insertions(+), 42 deletions(-) create mode 100644 tests/fortran/generic_interfaces/semantics/test_generic_contributor_merging.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 179ce8360..49aad264d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ release tags add a leading `v` to the package version. ## Unreleased +- A generic assembles the specifics of every accessible interface that + contributes to it. A module importing `convert` from two modules that each + declare a generic of that name kept only the first, silently losing the + other's specific procedures, and importing both without declaring one locally + dropped the name entirely as an ambiguity. Contributors are now gathered in + source order through every route, transitively, with one declaration counted + once; accessibility still applies at each hop, and a generic is still not + published into a second Python namespace. + - A use-associated name is resolved from every route carrying it, whichever way each route entered. A module writing `use a_mod, only : x` beside a plain `use c_mod` that also offers `x` reaches two different entities, and the diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 2884d289a..442edf21b 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -1848,10 +1848,18 @@ def _reconcile_routes(origins: list[tuple[str, str, str]]) -> tuple[str, str, st local name reaches more than one thing, and choosing between them would be a guess rather than a reading. + A generic is the exception the language makes. Several accessible + generic interfaces spelled the same contribute their specific procedures + to one generic, so generic routes are contributors rather than rivals. + The merged specifics belong to the overload set assembled from them; + this record names the first contributor in source order, which is the + route the association is reported through. """ distinct = list(dict.fromkeys(origins)) if len(distinct) == 1: return distinct[0] + if distinct and all(kind == "generic" for kind, _module, _name in distinct): + return distinct[0] return None @classmethod @@ -3543,60 +3551,87 @@ def _inherited_generic_specifics( generic_name: str, modules: dict[str, FortranModule], ) -> tuple[list[str], dict[str, SemanticFunction]]: - """Return the specifics one generic inherits from the generic it extends. + """Return the specifics one generic inherits from the generics 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. + extends that generic rather than replacing it, and the language lets + several accessible generics of one name contribute at once, so every + contributor is read rather than the first route that matches. One + declaration reached by two routes contributes once. Accumulation runs + one way: a 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) + inherited: list[str] = [] + lookup: dict[str, SemanticFunction] = {} + for source_module, source_generic in self._imported_generic_interfaces(module, 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( + def _module_generic_interface(module: FortranModule, name: str) -> FortranInterface | None: + """Return the module-scope generic one module declares under ``name``. + + A block written inside a contained procedure belongs to that procedure, + so it never contributes to what a ``use`` of the module reaches, and an + abstract block declares prototypes rather than a generic. + """ + return next( + ( + item + for item in FortranToIRConverter._module_interfaces(module) + if item.name and not item.abstract and item.name.casefold() == name.casefold() + ), + None, + ) + + @classmethod + def _imported_generic_interfaces( + cls, 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()) + seen: frozenset[tuple[str, str]] = frozenset(), + ) -> tuple[tuple[FortranModule, FortranInterface], ...]: + """Return every accessible generic one module imports under one name. + + A generic is not a single-origin entity: accessible generic interfaces + sharing an identifier all contribute their specifics to it. Every route + carrying the name is therefore followed, in source order, and a module + that re-exports the name rather than declaring a generic is walked + through to its own contributors. Fortran accessibility applies at each + hop, so a route a module makes private carries nothing onward. + """ + contributors: list[tuple[FortranModule, FortranInterface]] = [] + for route in cls._name_routes(module, modules, generic_name): + source_module = modules.get(route.used_module.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() - ] + key = (source_module.name.casefold(), route.source_name.casefold()) + if key in seen: + continue + onward = cls._name_routes(source_module, modules, route.source_name) + route_names = tuple(dict.fromkeys(item.used_module for item in onward)) + if not cls._effective_accessibility(source_module)(route.source_name, route_names): + continue + declared = cls._module_generic_interface(source_module, route.source_name) + if declared is not None: + contributors.append((source_module, declared)) + contributors.extend( + cls._imported_generic_interfaces(source_module, route.source_name, modules, seen | {key}) ) - 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 + # The same declaration reached by more than one route contributes once. + unique: dict[int, tuple[FortranModule, FortranInterface]] = {} + for contributor in contributors: + unique.setdefault(id(contributor[1]), contributor) + return tuple(unique.values()) @staticmethod def _resolve_overload_targets( diff --git a/tests/fortran/generic_interfaces/semantics/test_generic_contributor_merging.py b/tests/fortran/generic_interfaces/semantics/test_generic_contributor_merging.py new file mode 100644 index 000000000..1e58cc435 --- /dev/null +++ b/tests/fortran/generic_interfaces/semantics/test_generic_contributor_merging.py @@ -0,0 +1,236 @@ +"""An accessible generic is assembled from every interface that contributes to it. + +An ordinary entity has one declaration, so two routes naming different ones +leave a local name ambiguous. A generic is the exception the language makes: +accessible generic interfaces sharing an identifier all contribute their +specific procedures to one generic, so every contributing route is read rather +than the first that matches. +""" + +from pathlib import Path + +from prik.parsers.fortran import parse_fortran_project +from prik.semantics.fortran2ir import fortran_project_to_semantic_modules + +CONTRIBUTORS = """\ +module ints_mod + implicit none + interface convert + module procedure convert_i + end interface +contains + integer function convert_i(x) + integer, intent(in) :: x + convert_i = x + end function convert_i +end module ints_mod + +module reals_mod + implicit none + interface convert + module procedure convert_r + end interface +contains + real function convert_r(x) + real, intent(in) :: x + convert_r = x + end function convert_r +end module reals_mod +""" + +LOCAL_EXTENSION = """\ + interface convert + module procedure convert_l + end interface + +contains + logical function convert_l(x) + logical, intent(in) :: x + convert_l = x + end function convert_l +end module facade_mod +""" + + +def _modules(tmp_path: Path, *sources: str): + """Parse one throwaway project and return its semantic modules by name.""" + (tmp_path / "project.f90").write_text("\n".join(sources), encoding="utf-8") + return {module.name: module for module in fortran_project_to_semantic_modules(parse_fortran_project(str(tmp_path)))} + + +def _specifics(module, generic_name: str) -> list[str]: + """Return the specific procedures one module's generic dispatches over.""" + return [ + procedure.name + for overload_set in module.overload_sets + if overload_set.name == generic_name + for procedure in overload_set.procedures + ] + + +def test_two_imported_generics_both_contribute_their_specifics(tmp_path: Path): + """Neither import replaces the other, so the generic dispatches over both.""" + modules = _modules( + tmp_path, + CONTRIBUTORS, + """\ +module facade_mod + use ints_mod, only : convert + use reals_mod, only : convert + implicit none +""" + + LOCAL_EXTENSION, + ) + + assert _specifics(modules["facade_mod"], "convert") == ["convert_i", "convert_r", "convert_l"] + + +def test_an_imported_generic_reached_twice_contributes_once(tmp_path: Path): + """One declaration is one contributor however many routes reach it.""" + modules = _modules( + tmp_path, + CONTRIBUTORS, + """\ +module hop_mod + use ints_mod, only : convert + implicit none +end module hop_mod + +module facade_mod + use ints_mod, only : convert + use hop_mod, only : convert + implicit none +""" + + LOCAL_EXTENSION, + ) + + assert _specifics(modules["facade_mod"], "convert") == ["convert_i", "convert_l"] + + +def test_a_private_generic_route_contributes_nothing(tmp_path: Path): + """Accessibility applies to a generic route as it does to any other.""" + modules = _modules( + tmp_path, + CONTRIBUTORS, + """\ +module hop_mod + use reals_mod, only : convert + implicit none + private :: convert +end module hop_mod + +module facade_mod + use ints_mod, only : convert + use hop_mod, only : convert + implicit none +""" + + LOCAL_EXTENSION, + ) + + assert _specifics(modules["facade_mod"], "convert") == ["convert_i", "convert_l"] + + +def test_generic_contributors_survive_a_transitive_chain(tmp_path: Path): + """A module extending a merged generic inherits everything it reaches.""" + modules = _modules( + tmp_path, + CONTRIBUTORS, + """\ +module middle_mod + use ints_mod, only : convert + use reals_mod, only : convert + implicit none + interface convert + module procedure convert_m + end interface +contains + double precision function convert_m(x) + double precision, intent(in) :: x + convert_m = x + end function convert_m +end module middle_mod + +module facade_mod + use middle_mod, only : convert + implicit none +""" + + LOCAL_EXTENSION, + ) + + assert _specifics(modules["middle_mod"], "convert") == ["convert_i", "convert_r", "convert_m"] + assert sorted(_specifics(modules["facade_mod"], "convert")) == [ + "convert_i", + "convert_l", + "convert_m", + "convert_r", + ] + + +def test_two_imported_generics_remain_one_accessible_name(tmp_path: Path): + """Generic routes are contributors, so they do not cancel each other out.""" + modules = _modules( + tmp_path, + CONTRIBUTORS, + """\ +module facade_mod + use ints_mod, only : convert + use reals_mod, only : convert + implicit none +end module facade_mod +""", + ) + + reexports = {item.local_name: item for item in modules["facade_mod"].reexports} + assert reexports["convert"].entity_kind == "generic" + + +def test_a_generic_and_a_variable_of_one_name_are_not_merged(tmp_path: Path): + """Different kinds of entity are a genuine ambiguity, not a contribution.""" + modules = _modules( + tmp_path, + CONTRIBUTORS, + """\ +module holder_mod + implicit none + integer :: convert = 3 +end module holder_mod + +module facade_mod + use ints_mod, only : convert + use holder_mod, only : convert + implicit none +end module facade_mod +""", + ) + + assert [item.local_name for item in modules["facade_mod"].reexports] == [] + + +def test_a_procedure_local_generic_stays_inside_its_procedure(tmp_path: Path): + """A block written inside a procedure is not part of the module's interface.""" + modules = _modules( + tmp_path, + """\ +module owner_mod + implicit none +contains + subroutine run() + interface convert + module procedure convert_p + end interface + end subroutine run + + integer function convert_p(x) + integer, intent(in) :: x + convert_p = x + end function convert_p +end module owner_mod + +module facade_mod + use owner_mod, only : convert + implicit none +end module facade_mod +""", + ) + + assert [item.entity_kind for item in modules["facade_mod"].reexports if item.local_name == "convert"] == ["unknown"] From 95c3ca1c691bb9060b3d70e26d58eb2495c38bae Mon Sep 17 00:00:00 2001 From: said Date: Fri, 18 Sep 2026 05:39:56 +0100 Subject: [PATCH 63/96] Identify a prototype by its scope, and allocate its spelling once A procedure-local prototype took its contract name by joining its declaring scope to its declared name, and that joined text was also its identity. Two different declarations can produce it: module scope :: first_cb -> first_cb procedure first :: cb -> first_cb procedure a_b :: c -> a_b_c procedure a :: b_c -> a_b_c so the second was dropped as already seen and its callback was annotated with the first's signature -- a contract stating `integer` where the source declares `real`. Separate the two questions. A prototype's identity is structural: the module declaring it, the contained procedure owning the block, and the name that scope gives it, which `SemanticPrototype.declaring_scope` and a callback's `prototype_ref` both carry. The Python spelling is then allocated once, against the names the module already holds, so a collision moves a spelling aside instead of merging two declarations. A module's own block keeps its declared name, which is what another module imports it by, and a prototype is written as declared rather than case-folded. Both sides read that one settled name: `SemanticPrototype.name` and every callback annotation are assigned from the same allocation, so the synthetic spelling is never derived twice. The module declaring a prototype is part of its identity too, so an imported prototype sharing a native name with a local one keeps its own spelling. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 10 ++ prik/semantics/fortran2ir.py | 156 +++++++++++++++--- prik/semantics/models.py | 10 ++ .../test_fortran_callback_semantics.py | 3 + .../semantics/test_declaration_publication.py | 113 +++++++++++++ 5 files changed, 266 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49aad264d..2fedad043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ release tags add a leading `v` to the package version. ## Unreleased +- A prototype is identified by the scope declaring it, and its contract + spelling is allocated against the names the module already holds. Joining the + scope to the name produced a spelling that could collide with a real + declaration -- a module-level `first_cb` beside `first`'s own `cb` -- so the + two became one prototype and a callback was typed by the other's signature. + Two scopes whose joined spellings coincided (`a_b` declaring `c`, `a` + declaring `b_c`) collided the same way. Each now keeps a distinct contract + name, and a module's own block still publishes the spelling another module + imports it by. + - A generic assembles the specifics of every accessible interface that contributes to it. A module importing `convert` from two modules that each declare a generic of that name kept only the first, silently losing the diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 442edf21b..07ad07b4f 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -64,6 +64,7 @@ SEMANTIC_SCALAR_TYPE_NAMES, is_boolean_semantic_type_name, ) +from prik.naming import NamingPolicy from prik.utilities.visitor import ClassVisitor from prik.semantics.models import ( @@ -207,19 +208,6 @@ def visible_name(self) -> str: """Return the canonical spelling visible where the interface was resolved.""" return self.local_name or self.signature.name - @property - def contract_name(self) -> str: - """Return the spelling a contract writes for this interface. - - Two contained procedures may each declare an interface of the same - name meaning different signatures, so a procedure-local block is named - for the scope that owns it. A module's own block keeps its name, which - is the one another module imports. - """ - if not self.declaring_scope: - return self.visible_name - return "_".join((*self.declaring_scope, self.visible_name)) - class _NameRoute(NamedTuple): """One way a module reaches a name: the module used, and the name there.""" @@ -991,7 +979,8 @@ def _callback_semantic_type( # 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.contract_name if resolved is not None else interface_name + local_name = resolved.visible_name if resolved is not None else interface_name + declaring_scope = resolved.declaring_scope if resolved is not None else () return SemanticType( local_name, dtype="Prototype", @@ -1003,6 +992,11 @@ def _callback_semantic_type( "name": native_name, "local_name": local_name, "origin_module": prototype_module, + # The scope declaring the interface completes its identity: + # two procedures may each declare a `cb` meaning different + # signatures, and the contract spelling is settled from this + # identity once, then read here. + "declaring_scope": tuple(declaring_scope), }, "native_callback_kind": signature.kind, "callback_lifetime": "call", @@ -1129,11 +1123,116 @@ def _record_prototype_argument_intent( if intent is not None: argument.origin.metadata[PROTOTYPE_INTENT_METADATA] = intent + def _settle_prototype_contract_names( + self, + module: FortranModule, + prototypes: list[SemanticPrototype], + functions: list[SemanticFunction], + classes: list[SemanticClass], + ) -> None: + """Give each prototype identity one contract spelling, read by both sides. + + A prototype's identity is its declaring scope and the name that scope + gives it, which two contained procedures may spell the same. The Python + spelling is therefore allocated here, once, against the names this + module already holds -- a procedure-local block suggests its scope and + name, and the allocator settles any collision with a module-level + declaration or another scope. Every annotation naming the prototype then + reads the settled spelling rather than rebuilding one. + """ + if not prototypes: + return + # A prototype is written as it is declared, so the spelling is kept and + # only a collision moves one aside; case folding belongs to the public + # names a build publishes, which a prototype is not. + naming = NamingPolicy(preserve_case=True) + # A module's own block declares the name another module imports, so it + # keeps it; every other declared name is held first so no prototype can + # be handed a spelling that already belongs to one. + module_scope = { + str(prototype.native_name or prototype.name).casefold() + for prototype in prototypes + if not prototype.declaring_scope + } + for name in sorted(self._module_declared_names(module)): + if name in module_scope: + continue + naming.reserve_public_name((), name, category="function", owner=("declared", name)) + settled: dict[tuple[str, tuple[str, ...], str], str] = {} + for prototype in sorted(prototypes, key=lambda item: bool(item.declaring_scope)): + identity = ( + module.name.casefold(), + tuple(prototype.declaring_scope), + str(prototype.native_name or prototype.name).casefold(), + ) + suggestion = "_".join((*prototype.declaring_scope, str(prototype.native_name or prototype.name))) + prototype.name = naming.reserve_public_name( + (), + suggestion, + category="function", + owner=("prototype", identity), + ) + settled[identity] = prototype.name + for semantic_type in self._module_semantic_types(prototypes, functions, classes): + identity = self._prototype_reference_identity(semantic_type) + contract_name = settled.get(identity) if identity is not None else None + if contract_name is None: + continue + semantic_type.name = contract_name + semantic_type.metadata[PROTOTYPE_REF_METADATA]["local_name"] = contract_name + + @classmethod + def _module_semantic_types( + cls, + prototypes: list[SemanticPrototype], + functions: list[SemanticFunction], + classes: list[SemanticClass], + ) -> Iterable[SemanticType]: + """Yield every semantic type one module's declarations carry.""" + callables: list[SemanticFunction] = [*prototypes, *functions] + pending = list(classes) + while pending: + declaration = pending.pop() + callables.extend(declaration.methods) + pending.extend(declaration.classes) + for field in declaration.fields: + if field.semantic_type is not None: + yield field.semantic_type + for callable_item in callables: + for argument in (*callable_item.arguments, *callable_item.locals): + if argument.semantic_type is not None: + yield argument.semantic_type + if callable_item.return_type is not None: + yield callable_item.return_type + + @staticmethod + def _prototype_reference_identity( + semantic_type: SemanticType | None, + ) -> tuple[str, tuple[str, ...], str] | None: + """Return the prototype identity one callback annotation refers to. + + A prototype is identified by the module declaring it, the contained + procedure owning the block if any, and the name that scope gives it. + The module matters because another one may declare its own prototype + under the same spelling, and the two are different signatures. + """ + if semantic_type is None or semantic_type.storage is None or semantic_type.storage.kind != "callback": + return None + reference = semantic_type.metadata.get(PROTOTYPE_REF_METADATA) + if not isinstance(reference, dict): + return None + scope = tuple(str(part) for part in reference.get("declaring_scope", ())) + return ( + str(reference.get("origin_module", "")).casefold(), + scope, + str(reference.get("name", "")).casefold(), + ) + def _module_prototypes( self, module: FortranModule, context: _DerivedTypeContext, - referenced: set[str], + referenced: set[tuple[str, tuple[str, ...], str]], called: set[str], ) -> list[SemanticPrototype]: """Convert every referenced interface into one exact prototype signature. @@ -1146,20 +1245,19 @@ def _module_prototypes( cannot reach it, so the module does not publish it. """ prototypes: list[SemanticPrototype] = [] - seen: set[str] = set() + seen: set[tuple[str, tuple[str, ...], str]] = set() for interface in module.interfaces: scope = self._interface_declaring_scope(interface) for signature in interface.procedures: declared = interface.name if interface.name and len(interface.procedures) == 1 else signature.name - name = "_".join((*scope, declared)) - # A callback argument records the contract identity, which for - # a procedure-local block is the scope-qualified one; a shape - # calling the name still spells it as the source declares it. - if not (interface.abstract or name.casefold() in referenced or declared.casefold() in called): + # Identity is the declaring scope with the name that scope + # gives; the contract spelling for it is settled afterwards. + identity = (module.name.casefold(), scope, declared.casefold()) + if not (interface.abstract or identity in referenced or declared.casefold() in called): continue - if name in seen: + if identity in seen: continue - seen.add(name) + seen.add(identity) arguments = [self.visit(item, derived_type_context=context) for item in signature.arguments] for source_argument, argument in zip(signature.arguments, arguments, strict=True): self._normalize_callback_reference_storage(argument, source_argument) @@ -1171,8 +1269,9 @@ def _module_prototypes( ) prototypes.append( SemanticPrototype( - name=name, + name=declared, native_name=declared, + declaring_scope=scope, arguments=arguments, return_type=return_type, metadata=self._procedure_metadata(signature), @@ -1469,11 +1568,15 @@ def _visit_FortranModule( ) for proc in source_procedures ] + # A callback argument names the prototype identity it resolved to, so + # the prototypes to convert are read from those identities rather than + # rediscovered from the spellings the annotations happen to use. callback_prototypes = { - argument.semantic_type.name.casefold() + identity for function in semantic_functions for argument in function.arguments - if argument.semantic_type.storage is not None and argument.semantic_type.storage.kind == "callback" + for identity in (self._prototype_reference_identity(argument.semantic_type),) + if identity is not None } prototypes = self._module_prototypes( module, @@ -1553,6 +1656,7 @@ def _visit_FortranModule( prototypes=prototypes, ), ) + self._settle_prototype_contract_names(module, prototypes, semantic_functions, semantic_classes) return SemanticModule( name=module.name, functions=semantic_functions, diff --git a/prik/semantics/models.py b/prik/semantics/models.py index 8a2231886..ac9364984 100644 --- a/prik/semantics/models.py +++ b/prik/semantics/models.py @@ -375,6 +375,16 @@ class SemanticPrototype(SemanticFunction): pure: bool = False + declaring_scope: tuple[str, ...] = () + """Contained procedure declaring the interface, empty for a module's own. + + A prototype's identity is structural -- the scope that declares it together + with the name that scope gives it -- because two procedures may each declare + a different signature under one spelling. ``name`` carries the contract + spelling settled for that identity, which is allocated once and read + everywhere rather than rebuilt from the scope. + """ + # ============================================================ # Semantic Methods diff --git a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py index c744e94bc..5c6a18ffe 100644 --- a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py +++ b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py @@ -452,6 +452,9 @@ def test_procedure_local_rename_keeps_both_the_declared_and_local_names(): "name": "OBJ", "local_name": "LOCAL_OBJ", "origin_module": "ren_types", + # The block is the declaring module's own, so no contained procedure + # owns it and the identity carries an empty scope. + "declaring_scope": (), } diff --git a/tests/fortran/modules/semantics/test_declaration_publication.py b/tests/fortran/modules/semantics/test_declaration_publication.py index 3bf7478c7..065c24a33 100644 --- a/tests/fortran/modules/semantics/test_declaration_publication.py +++ b/tests/fortran/modules/semantics/test_declaration_publication.py @@ -124,3 +124,116 @@ def test_a_procedure_local_interface_is_never_a_module_publication(tmp_path: Pat assert "f: first_cb" in contract assert "f: second_cb" in contract assert '__all__ = ["first", "second"]' in contract + + +MODULE_AND_LOCAL_SOURCE = """\ +module m + implicit none + abstract interface + subroutine first_cb(x) + integer :: x + end subroutine + end interface +contains + subroutine first(f) + abstract interface + subroutine cb(x) + real :: x + end subroutine + end interface + procedure(cb) :: f + call f(1.0) + end subroutine first + + subroutine uses_module_one(g) + procedure(first_cb) :: g + call g(1) + end subroutine uses_module_one +end module m +""" + +JOINED_COLLISION_SOURCE = """\ +module m + implicit none +contains + subroutine a_b(f) + abstract interface + subroutine c(x) + integer :: x + end subroutine + end interface + procedure(c) :: f + call f(1) + end subroutine a_b + + subroutine a(f) + abstract interface + subroutine b_c(x) + real :: x + end subroutine + end interface + procedure(b_c) :: f + call f(1.0) + end subroutine a +end module m +""" + + +def _callback_annotations(module) -> dict[str, tuple[str, str]]: + """Return each callback argument's contract name and first argument type.""" + return { + f"{function.name}.{argument.name}": ( + argument.semantic_type.name, + argument.semantic_type.metadata["arguments"][0].name, + ) + for function in module.functions + for argument in function.arguments + if argument.semantic_type.storage is not None and argument.semantic_type.storage.kind == "callback" + } + + +def test_a_prototype_is_identified_by_its_scope_rather_than_its_spelling(tmp_path: Path): + """A module block and a procedure block are different declarations. + + Naming the procedure-local one by joining its scope to its name produces + the module block's own spelling, so the two would be one prototype and one + of the callbacks would be given the other's signature. + """ + module = _module(MODULE_AND_LOCAL_SOURCE, tmp_path) + + identities = [(item.native_name, item.declaring_scope, item.visibility) for item in module.prototypes] + assert identities == [("first_cb", (), "public"), ("cb", ("first",), "private")] + + # The module's own block keeps the spelling another module imports it by. + names = [item.name for item in module.prototypes] + assert names[0] == "first_cb" + assert names[1] != "first_cb" + + annotations = _callback_annotations(module) + assert annotations["uses_module_one.g"] == ("first_cb", "Int32") + assert annotations["first.f"] == (names[1], "Float32") + + +def test_scopes_whose_joined_spellings_collide_keep_distinct_contract_names(tmp_path: Path): + """`a_b` declaring `c` and `a` declaring `b_c` are different prototypes.""" + module = _module(JOINED_COLLISION_SOURCE, tmp_path) + + names = [item.name for item in module.prototypes] + assert len(set(names)) == 2 + + annotations = _callback_annotations(module) + assert annotations["a_b.f"] == (names[0], "Int32") + assert annotations["a.f"] == (names[1], "Float32") + + +def test_a_contract_writes_one_prototype_for_each_scope(tmp_path: Path): + """Both prototypes are written, and only the module's own is published.""" + module = _module(MODULE_AND_LOCAL_SOURCE, tmp_path) + contract = PyiPrinter().emit(module) + local_name = module.prototypes[1].name + + assert "def first_cb(\n x: Int32[()]\n) -> None: ..." in contract + assert f"def {local_name}(\n x: Float32[()]\n) -> None: ..." in contract + assert f"f: {local_name}" in contract + assert "g: first_cb" in contract + assert '__all__ = ["first_cb", "first", "uses_module_one"]' in contract From e713692fb149d8982b9152018e434ac937d61591 Mon Sep 17 00:00:00 2001 From: said Date: Fri, 18 Sep 2026 05:41:21 +0100 Subject: [PATCH 64/96] Let a contract's __all__ decide what reloading it publishes A generated contract writes a private prototype and a private generic into its body -- annotations and dispatch resolve against them -- while leaving both out of `__all__`. Read back, each returns with the default `visibility` of `"public"`, and `complete_python_export_policy()` duly completed a public export for the generic. The printer masked it by writing the stated `__all__` verbatim, so the completed policy and the emitted contract disagreed about the module's surface, and any other reader of that policy would have believed the first. `__all__` is what a contract states about its publication, so read it where publication is completed and let it decide. A declaration left out gets no export; it stays written, keeps the accessibility the contract gave it, and remains reachable for annotations and for another module's import -- which is why this is not a visibility change: marking such a declaration private would also make importing it fail, and a contract may legitimately name one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 7 ++ prik/policy/exports.py | 20 ++++ .../test_contract_publication_round_trip.py | 112 ++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fedad043..43c159ccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ release tags add a leading `v` to the package version. ## Unreleased +- A contract's `__all__` decides what it publishes when it is read back. A + prototype and a generic are written into the body so annotations and dispatch + resolve, and both read back public by default, so a contract that withheld + them from `__all__` still had a public export completed for them. Export + policy now names only the surface the contract states; the declarations stay + written and reachable for naming and import resolution. + - A prototype is identified by the scope declaring it, and its contract spelling is allocated against the names the module already holds. Joining the scope to the name produced a spelling that could collide with a real diff --git a/prik/policy/exports.py b/prik/policy/exports.py index 3ef5eee48..25f6df07f 100644 --- a/prik/policy/exports.py +++ b/prik/policy/exports.py @@ -29,6 +29,17 @@ class PythonExportPolicy: name: str +def _stated_export_names(module: models.SemanticModule) -> set[str] | None: + """Return the surface one contract states, or ``None`` when it states none. + + A contract that writes no ``__all__`` publishes what it declares, so there + is nothing stated to read and every declaration is completed as before. + """ + if module.exported_names is None: + return None + return {str(name).casefold() for name in module.exported_names} + + def complete_python_export_policy( module: models.SemanticModule, *, @@ -40,9 +51,16 @@ def complete_python_export_policy( contract states the names it publishes -- so those spellings are kept exactly. Only a module converted from native source has names PRIK must choose, and only where the source language has no spelling of its own. + + Such a contract also states its whole surface in ``__all__``, which is the + authority on what it publishes. A declaration it leaves out stays written + and reachable, because annotations and imports resolve against it, and no + export is completed for it -- a prototype or a generic reads back public by + default, and completing one would publish what the contract declined to. """ contract_named = bool(module.metadata.get(PYI_LOADED_METADATA)) complete_reexport_publication_policy(module, contract_named=contract_named) + stated = _stated_export_names(module) naming = NamingPolicy( strict_public_names=strict_wrapper_names, preserve_case=contract_named or preserves_source_case(module.origin.source_language), @@ -50,6 +68,8 @@ def complete_python_export_policy( for owner in _module_export_owners(module): if getattr(owner, "visibility", "public") == "private": continue + if stated is not None and str(owner.name).casefold() not in stated: + continue metadata = _owner_metadata(owner) exports = metadata.get(models.PYTHON_EXPORTS_METADATA) if not exports: diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py new file mode 100644 index 000000000..ac0a19234 --- /dev/null +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py @@ -0,0 +1,112 @@ +"""A reloaded contract publishes what its `__all__` states, and nothing else. + +A generated contract writes a private prototype and a private generic into its +body, because annotations and dispatch resolve against them, while leaving both +out of `__all__`. Reading that contract back must not turn either into a public +declaration merely because it is still written there. +""" + +from pathlib import Path + +import pytest + +from prik.pipeline.pyi import pyi_paths_to_semantic_modules +from prik.policy.exports import complete_python_export_policy +from prik.printers.pyi import PyiPrinter +from prik.semantics.models import PYTHON_EXPORTS_METADATA, ProcedureOverloadSet +from prik.semantics.fortran2ir import fortran_file_to_semantic_modules +from prik.parsers.fortran import parse_fortran_file + +SOURCE = """\ +module v_mod + implicit none + private + + abstract interface + subroutine cb() + end subroutine + end interface + + interface hidden_generic + module procedure hidden_one + end interface + + public :: run +contains + subroutine run(f) + procedure(cb) :: f + call f() + end subroutine run + + subroutine hidden_one(a) + integer, intent(in) :: a + print *, a + end subroutine hidden_one +end module v_mod +""" + + +@pytest.fixture +def generated_contract(tmp_path: Path) -> Path: + """Write the source-derived contract for the shared module.""" + source = tmp_path / "v.f90" + source.write_text(SOURCE, encoding="utf-8") + module = fortran_file_to_semantic_modules(parse_fortran_file(SOURCE, filename=str(source)))[0] + contract = tmp_path / "v_mod.pyi" + contract.write_text(PyiPrinter().emit(module), encoding="utf-8") + return contract + + +def test_a_contract_states_the_surface_without_publishing_its_helpers(generated_contract: Path): + """The prototype and the generic are written but not exported.""" + text = generated_contract.read_text(encoding="utf-8") + + assert "def cb() -> None: ..." in text + assert "def hidden_generic(" in text + assert '__all__ = ["run"]' in text + + +def test_reloading_a_contract_does_not_publish_what_all_leaves_out(generated_contract: Path): + """`__all__` is the stated surface, so no export is completed outside it. + + A prototype and a generic read back public by default, and the declarations + stay written because annotations and imports resolve against them. Completed + export policy is what decides publication, and it names only the surface the + contract stated. + """ + reloaded = pyi_paths_to_semantic_modules([generated_contract])[0] + complete_python_export_policy(reloaded) + + assert reloaded.exported_names == ["run"] + assert [item.name for item in reloaded.prototypes] == ["cb"] + assert [item.name for item in reloaded.overload_sets] == ["hidden_generic"] + + published = { + str(owner.name): (owner.procedures[0] if isinstance(owner, ProcedureOverloadSet) else owner).metadata.get( + PYTHON_EXPORTS_METADATA + ) + for owner in (*reloaded.functions, *reloaded.overload_sets) + } + assert published["run"] == [{"namespace": (), "name": "run"}] + assert published["hidden_generic"] is None + assert published["hidden_one"] is None + + +def test_a_contract_read_back_and_written_again_states_the_same_surface(generated_contract: Path): + """Publication survives the round trip rather than drifting with each pass.""" + original = generated_contract.read_text(encoding="utf-8") + reloaded = pyi_paths_to_semantic_modules([generated_contract])[0] + + assert PyiPrinter().emit(reloaded).strip() == original.strip() + + +def test_a_withheld_declaration_is_still_reachable_for_naming(generated_contract: Path): + """Leaving a name out of `__all__` withholds publication, not reachability. + + Another module's annotation may still name the prototype, so the spelling + this contract writes it under stays readable; what `__all__` decides is + whether the module publishes it, which completed export policy settles. + """ + reloaded = pyi_paths_to_semantic_modules([generated_contract])[0] + + assert PyiPrinter().published_names(reloaded)["cb"] == "cb" From 45b058660256f0c6bb1280f87fc9ae7a594036bd Mon Sep 17 00:00:00 2001 From: said Date: Fri, 18 Sep 2026 07:08:13 +0100 Subject: [PATCH 65/96] Record whether a use statement narrowed to an only list `_parse_use_statement()` recognized `only :` and then threw that fact away, so a scope's import table could not tell use m, only : p => q carries p, and nothing else use m, p => q carries p, and everything else m publishes apart: both left one mapping, and every reader took a non-empty mapping list to mean the `use` was narrowed. A module renaming one name therefore lost access to every other name its module offered, and route collection saw no wildcard where the language provides one. Record it on the mapping, where the reading that produced it belongs, and read it in the three places that ask what a `use` carries. A rename also makes the entity reachable only by its local name, so the carried set excludes the name it renamed away. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 6 ++ prik/parsers/fortran/models.py | 9 +++ prik/parsers/fortran/parser.py | 2 +- prik/semantics/fortran2ir.py | 37 ++++++++-- .../fixtures/general/module_vars_use.json | 12 ++-- .../semantics/test_reexport_accessibility.py | 68 +++++++++++++++++++ 6 files changed, 122 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43c159ccd..a0998d967 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ release tags add a leading `v` to the package version. ## Unreleased +- A `use` that only renames still carries the rest of its module. The parser + recorded `use m, p => q` exactly as `use m, only : p => q`, so everything + else `m` publishes was dropped, and the renamed entity was also still + reachable under its own spelling. A mapping now records whether its statement + narrowed to an `only` list, which is what separates the two forms. + - A contract's `__all__` decides what it publishes when it is read back. A prototype and a generic are written into the body so annotations and dispatch resolve, and both read back public by default, so a contract that withheld diff --git a/prik/parsers/fortran/models.py b/prik/parsers/fortran/models.py index 08d8460be..a812140f6 100644 --- a/prik/parsers/fortran/models.py +++ b/prik/parsers/fortran/models.py @@ -347,6 +347,15 @@ class FortranUseMapping: source: str target: str | None = None + only: bool = True + """Whether the ``use`` listing this name narrowed to an ``only`` list. + + ``use m, only : x`` brings in ``x`` alone, while ``use m, p => q`` renames + one entity and still carries everything else the module offers. Both record + a mapping, so what separates them is kept here rather than inferred from a + list being non-empty. + """ + def __eq__(self, other: object) -> bool: if isinstance(other, str): return self.local_name == other diff --git a/prik/parsers/fortran/parser.py b/prik/parsers/fortran/parser.py index 7454c1b8b..35df11b83 100644 --- a/prik/parsers/fortran/parser.py +++ b/prik/parsers/fortran/parser.py @@ -5868,7 +5868,7 @@ def _parse_use_statement(line: str) -> tuple[str, list[FortranUseMapping]] | Non else: source = token target = None - mappings.append(FortranUseMapping(source=source, target=target)) + mappings.append(FortranUseMapping(source=source, target=target, only=only_match is not None)) return match.group("module"), mappings diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 07ad07b4f..a2a353b93 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -1932,15 +1932,17 @@ def _module_public_names( is_public = cls._effective_accessibility(module) offered: dict[str, set[str]] = {name: set() for name in cls._module_declared_names(module)} for module_name, mappings in module.uses.items(): - if mappings: - for mapping in mappings: - offered.setdefault(mapping.local_name.casefold(), set()).add(module_name) + for mapping in mappings: + offered.setdefault(mapping.local_name.casefold(), set()).add(module_name) + if not cls._carries_every_public_name(mappings): continue used = index.get(module_name.casefold()) if used is None: continue + renamed_away = cls._renamed_away_names(mappings) for name in cls._module_public_names(used, index, seen): - offered.setdefault(name, set()).add(module_name) + if name not in renamed_away: + offered.setdefault(name, set()).add(module_name) return {name for name, routes in offered.items() if is_public(name, routes)} @staticmethod @@ -1966,6 +1968,25 @@ def _reconcile_routes(origins: list[tuple[str, str, str]]) -> tuple[str, str, st return distinct[0] return None + @staticmethod + def _carries_every_public_name(mappings: list[FortranUseMapping]) -> bool: + """Return whether one ``use`` brings in more than the names it lists. + + Only an ``only`` list narrows a ``use``. A bare ``use`` carries every + public name, and so does one that merely renames: ``use m, p => q`` + binds ``p`` and still carries everything else ``m`` offers. + """ + return not mappings or any(not mapping.only for mapping in mappings) + + @staticmethod + def _renamed_away_names(mappings: list[FortranUseMapping]) -> set[str]: + """Return the names a rename makes unreachable under their own spelling. + + ``use m, p => q`` accesses that entity as ``p``; ``q`` does not name it + here, so the carried set excludes it. + """ + return {mapping.source.casefold() for mapping in mappings if mapping.target} + @classmethod def _name_routes( cls, @@ -1991,7 +2012,7 @@ def _name_routes( if mapping.local_name.casefold() == folded ] for used_name, mappings in module.uses.items(): - if mappings: + if not cls._carries_every_public_name(mappings) or folded in cls._renamed_away_names(mappings): continue used = index.get(used_name.casefold()) if used is not None and folded in cls._module_public_names(used, index): @@ -2016,13 +2037,15 @@ def _use_associated_names( for mapping in mappings: names.setdefault(mapping.local_name.casefold(), mapping.local_name) for used_name, mappings in module.uses.items(): - if mappings: + if not cls._carries_every_public_name(mappings): continue used = index.get(used_name.casefold()) if used is None: continue + renamed_away = cls._renamed_away_names(mappings) for name in sorted(cls._module_public_names(used, index)): - names.setdefault(name, name) + if name not in renamed_away: + names.setdefault(name, name) return tuple(names.values()) def _module_reexports( diff --git a/tests/fortran/infrastructure/parsing/fixtures/general/module_vars_use.json b/tests/fortran/infrastructure/parsing/fixtures/general/module_vars_use.json index dd4fa0c4d..3e2cf01d9 100644 --- a/tests/fortran/infrastructure/parsing/fixtures/general/module_vars_use.json +++ b/tests/fortran/infrastructure/parsing/fixtures/general/module_vars_use.json @@ -11,11 +11,13 @@ "iso_c_binding": [ { "source": "c_int", - "target": null + "target": null, + "only": true }, { "source": "c_double", - "target": null + "target": null, + "only": true } ] }, @@ -96,11 +98,13 @@ "iso_c_binding": [ { "source": "c_int", - "target": null + "target": null, + "only": true }, { "source": "c_double", - "target": null + "target": null, + "only": true } ] }, diff --git a/tests/fortran/modules/semantics/test_reexport_accessibility.py b/tests/fortran/modules/semantics/test_reexport_accessibility.py index b1cc0228e..f68712359 100644 --- a/tests/fortran/modules/semantics/test_reexport_accessibility.py +++ b/tests/fortran/modules/semantics/test_reexport_accessibility.py @@ -863,3 +863,71 @@ def test_mixed_routes_through_an_intermediate_module_stay_unresolved(tmp_path: P reexports = {item.local_name: item for item in modules["outer_mod"].reexports} assert reexports["x"].entity_kind == "unknown" assert reexports["x"].origin_module == "middle_mod" + + +def test_a_rename_without_only_still_carries_the_rest_of_the_module(tmp_path: Path): + """Only an `only` list narrows a `use`; a rename just binds another name. + + `use a_mod, p => q` accesses that entity as `p` and still carries whatever + else `a_mod` offers. Reading a non-empty mapping list as an `only` list + dropped every other name the module publishes. + """ + modules = _project_modules( + tmp_path, + """\ +module a_mod + implicit none + integer :: q = 1 + integer :: other = 2 +end module a_mod + +module b_mod + use a_mod, p => q + implicit none +end module b_mod +""", + ) + + reexports = {item.local_name: (item.origin_module, item.source_name) for item in modules["b_mod"].reexports} + assert reexports == {"p": ("a_mod", "q"), "other": ("a_mod", "other")} + + +def test_an_only_list_still_carries_nothing_else(tmp_path: Path): + """The narrowing form keeps narrowing.""" + modules = _project_modules( + tmp_path, + """\ +module a_mod + implicit none + integer :: q = 1 + integer :: other = 2 +end module a_mod + +module b_mod + use a_mod, only : q + implicit none +end module b_mod +""", + ) + + assert {item.local_name for item in modules["b_mod"].reexports} == {"q"} + + +def test_a_renamed_entity_is_not_also_carried_under_its_own_name(tmp_path: Path): + """`use m, p => q` accesses the entity as `p`, so `q` names nothing here.""" + modules = _project_modules( + tmp_path, + """\ +module a_mod + implicit none + integer :: q = 1 +end module a_mod + +module b_mod + use a_mod, p => q + implicit none +end module b_mod +""", + ) + + assert {item.local_name for item in modules["b_mod"].reexports} == {"p"} From 78f3ee3396cb7714010d1a20689a621ff368b8e7 Mon Sep 17 00:00:00 2001 From: said Date: Fri, 18 Sep 2026 07:10:16 +0100 Subject: [PATCH 66/96] Identify a generic's specific by the module declaring it Merging the generics a module reaches made specific procedures collide. `_inherited_generic_specifics()` keyed them by bare name, and `_resolve_overload_targets()` looked them up the same way, so two contributors each declaring interface convert module procedure to_value end interface produced one candidate: the second was taken for the first and dropped, and the merged generic silently lost a signature it must dispatch over. A specific is identified by the module declaring it together with the name that module gives it. `_SpecificProcedure` carries that pair, the inherited, inline, own and type-bound lookups are all keyed by it, and a target names which declaration it means rather than which spelling. A contract then has to write two declarations spelled alike, so the emission records what it named each one -- by scope, not by spelling -- and an overload target reads that record. Without it both dispatchers named the first declaration. A class member is named inside its class, so the record covers module-level declarations only, exactly as the published-name record does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 7 ++ prik/printers/pyi.py | 46 ++++++-- prik/semantics/fortran2ir.py | 102 ++++++++++++------ .../test_generic_contributor_merging.py | 75 +++++++++++++ 4 files changed, 192 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0998d967..776e8ff10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ release tags add a leading `v` to the package version. ## Unreleased +- A merged generic keeps specifics that two contributing modules spell alike. + Specific procedures were looked up by name alone, so a second contributor's + `to_value` looked like the first and was dropped, losing a signature the + generic must dispatch over. A specific is now identified by the module + declaring it, and a contract writing two of them gives each its own Python + name and names it in the matching `@overload(...)`. + - A `use` that only renames still carries the rest of its module. The parser recorded `use m, p => q` exactly as `use m, only : p => q`, so everything else `m` publishes was dropped, and the renamed entity was also still diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index f55709cba..2309f138e 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -127,6 +127,13 @@ class _PyiEmissionContext: called is knowable only from the emission that named it. A module reading from this one asks for the published spelling rather than deriving one. """ + published_specifics: dict[tuple[str, str], str] = field(default_factory=dict) + """Declaring scope and native name to the spelling this contract wrote. + + A merged generic dispatches over specifics from more than one module, which + may spell one the same way, so an overload target naming only that spelling + names no single declaration. The scope completes the identity. + """ def contract(self, name: str) -> str: """Return one local contract spelling and record its required import.""" @@ -559,7 +566,15 @@ def _overload_target_name(candidate: SemanticFunction, context: _PyiEmissionCont return target # The specific was named while this same contract was rendered, and a # collision may have moved that name aside, so the naming it settled on - # is what the target has to state. + # is what the target has to state. A merged generic may dispatch over + # specifics two modules spell alike, so the scope declaring this one + # picks out which declaration the target means. + scope = str(getattr(candidate.origin, "native_scope", "") or "").casefold() + by_identity = ( + context.published_specifics.get((scope, target.casefold())) if not context.public_namespace else None + ) + if by_identity is not None: + return by_identity published = published_name(context.published_names, target) return published or context.normalized(target) @@ -2461,13 +2476,30 @@ def _callable_name( if not context.normalize_public_names or func.name.startswith("__"): return func.name settled = context.settled("function", func.name) - if settled is not None: - return context.publish(func.name, settled) - return context.public_name( - func.name, - category="method" if isinstance(func, SemanticMethod) else "function", - owner=owner if owner is not None else func, + name = ( + context.publish(func.name, settled) + if settled is not None + else context.public_name( + func.name, + category="method" if isinstance(func, SemanticMethod) else "function", + owner=owner if owner is not None else func, + ) ) + # Only a module-level declaration is named this way, exactly as + # `publish` records one: a class member is named inside its class. + identity = PyiPrinter._specific_identity(func) if not context.public_namespace else None + if identity is not None: + context.published_specifics.setdefault(identity, name) + return name + + @staticmethod + def _specific_identity(func: SemanticFunction) -> tuple[str, str] | None: + """Return the scope and native name identifying one declaration, if known.""" + scope = str(getattr(func.origin, "native_scope", "") or "") + native = str(func.native_name or func.name) + if not scope or not native: + return None + return scope.casefold(), native.casefold() @staticmethod def _reexport_name(reexport: SemanticReexport, context: _PyiEmissionContext) -> str: diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index a2a353b93..4aba828f9 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -209,6 +209,23 @@ def visible_name(self) -> str: return self.local_name or self.signature.name +class _SpecificProcedure(NamedTuple): + """One generic's specific: the module declaring it, and the name it gives it. + + Two modules may each declare a specific of the same name and contribute + both to one merged generic, so a specific is identified by where it is + declared rather than by its spelling alone. + """ + + module: str + name: str + + @property + def key(self) -> tuple[str, str]: + """Return the case-folded identity this specific is looked up by.""" + return self.module.casefold(), self.name.casefold() + + class _NameRoute(NamedTuple): """One way a module reaches a name: the module used, and the name there.""" @@ -3187,14 +3204,20 @@ def _module_overload_sets( # A generic written inside a procedure belongs to that # procedure, so it is never part of the module's own interface. continue + # A specific this module declares is identified by this module, so + # its own and its inline candidates are keyed the same way the + # inherited ones are. inline_lookup = { - signature.name.casefold(): self.visit( + _SpecificProcedure(module.name, signature.name).key: self.visit( signature, visibility=self._symbol_visibility(module, signature.name), derived_type_context=context, ) for signature in interface.procedures } + own_lookup = { + _SpecificProcedure(module.name, name).key: function for name, function in procedure_lookup.items() + } target_names, inherited_lookup = self._generic_target_names( module, interface, @@ -3203,7 +3226,7 @@ def _module_overload_sets( ) procedures, missing = self._resolve_overload_targets( target_names, - procedure_lookup | inline_lookup | inherited_lookup, + own_lookup | inline_lookup | inherited_lookup, visibility=self._symbol_visibility(module, interface.name), ) if missing or not procedures: @@ -3222,9 +3245,9 @@ 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 | inherited_lookup - for target_name, candidate in zip(target_names, constructor_set.procedures, strict=True): - if target_lookup[target_name.casefold()].visibility == "private": + target_lookup = own_lookup | inline_lookup | inherited_lookup + for target, candidate in zip(target_names, constructor_set.procedures, strict=True): + if target_lookup[target.key].visibility == "private": # A private specific is unreachable by name; the type # name is public and resolves to the same procedure. candidate.native_name = interface.name @@ -3240,9 +3263,9 @@ def _module_overload_sets( else module.name, visibility=self._symbol_visibility(module, interface.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": + target_lookup = own_lookup | inline_lookup | inherited_lookup + for target, candidate in zip(target_names, overload_set.procedures, strict=True): + if target_lookup[target.key].visibility == "private": candidate.native_name = interface.name candidate.metadata[BIND_TARGET_METADATA] = interface.name overload_sets.append(overload_set) @@ -3268,14 +3291,18 @@ def _bound_overload_sets( generic targets preserve the previous empty-placeholder behavior for ordinary procedure names and are otherwise omitted. """ - lookup = {method.name.casefold(): method for method in methods} + # A type-bound generic's specifics are this type's own methods, so + # they are identified by the module declaring the type. + owner_module = str(getattr(dtype, "module", "") or "") + lookup = {_SpecificProcedure(owner_module, method.name).key: method for method in methods} overload_sets: list[ProcedureOverloadSet] = [] for binding in dtype.generic_bindings: name = str(binding["name"]) attrs = {str(attr).casefold() for attr in binding.get("attrs", ())} visibility = "private" if "private" in attrs else "public" if "public" in attrs else None + targets = [_SpecificProcedure(owner_module, str(item)) for item in binding.get("targets", ())] procedures, missing = self._resolve_overload_targets( - list(binding.get("targets", ())), + targets, lookup, visibility=visibility, ) @@ -3285,8 +3312,8 @@ def _bound_overload_sets( continue if self._is_procedure_generic_name(name): overload_set = self._normal_overload_set(name, procedures) - for target_name, candidate in zip(binding.get("targets", ()), overload_set.procedures, strict=True): - if lookup[target_name.casefold()].visibility == "private": + for target, candidate in zip(targets, overload_set.procedures, strict=True): + if lookup[target.key].visibility == "private": candidate.native_name = name candidate.metadata[BIND_TARGET_METADATA] = name overload_sets.append(overload_set) @@ -3658,26 +3685,34 @@ def _generic_target_names( interface: FortranInterface, modules: dict[str, FortranModule], inherited_functions: list[SemanticFunction], - ) -> tuple[list[str], dict[str, SemanticFunction]]: + ) -> tuple[list[_SpecificProcedure], dict[tuple[str, 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. + the generics it extends, so the module can carry them for dispatch. A + specific is identified by its declaring module, so two contributors + that spell one the same way both survive. """ - inherited_names, inherited_lookup = self._inherited_generic_specifics(module, interface.name, modules) - known = {item.name.casefold() for item in inherited_functions} + inherited, inherited_lookup = self._inherited_generic_specifics(module, interface.name, modules) + known = { + (str(item.origin.native_scope or "").casefold(), str(item.native_name or 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 + inherited_lookup[target.key] for target in inherited if target.key not in known ) - own_names = interface.specific_procedures or [signature.name for signature in interface.procedures] - return [*inherited_names, *own_names], inherited_lookup + own = [ + _SpecificProcedure(module.name, name) + for name in (interface.specific_procedures or [signature.name for signature in interface.procedures]) + ] + return [*inherited, *own], inherited_lookup def _inherited_generic_specifics( self, module: FortranModule, generic_name: str, modules: dict[str, FortranModule], - ) -> tuple[list[str], dict[str, SemanticFunction]]: + ) -> tuple[list[_SpecificProcedure], dict[tuple[str, str], SemanticFunction]]: """Return the specifics one generic inherits from the generics it extends. A local interface block repeating a ``use``-associated generic name @@ -3687,19 +3722,20 @@ def _inherited_generic_specifics( declaration reached by two routes contributes once. Accumulation runs one way: a declaring module never sees what a later module adds. """ - inherited: list[str] = [] - lookup: dict[str, SemanticFunction] = {} + inherited: list[_SpecificProcedure] = [] + lookup: dict[tuple[str, str], SemanticFunction] = {} for source_module, source_generic in self._imported_generic_interfaces(module, 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: + target = _SpecificProcedure(source_module.name, name) signature = signatures.get(name.casefold()) - if signature is None or name.casefold() in lookup: + if signature is None or target.key in lookup: continue function = self.visit(signature, visibility="private", derived_type_context=source_context) - lookup[name.casefold()] = function - inherited.append(name) + lookup[target.key] = function + inherited.append(target) return inherited, lookup @staticmethod @@ -3762,18 +3798,22 @@ def _imported_generic_interfaces( @staticmethod def _resolve_overload_targets( - target_names: list[str], - procedure_lookup: dict[str, SemanticFunction], + targets: list[_SpecificProcedure], + procedure_lookup: dict[tuple[str, str], SemanticFunction], *, visibility: str | None, ) -> tuple[list[SemanticFunction], list[str]]: - """Copy resolved generic targets and list target names absent from ``procedure_lookup``.""" + """Copy resolved generic targets and name those absent from ``procedure_lookup``. + + A target is identified by the module declaring it, so two contributors + that spell a specific the same way stay two procedures. + """ procedures: list[SemanticFunction] = [] missing: list[str] = [] - for target_name in target_names: - procedure = procedure_lookup.get(target_name.casefold()) + for target in targets: + procedure = procedure_lookup.get(target.key) if procedure is None: - missing.append(target_name) + missing.append(target.name) continue candidate = deepcopy(procedure) if visibility is not None: diff --git a/tests/fortran/generic_interfaces/semantics/test_generic_contributor_merging.py b/tests/fortran/generic_interfaces/semantics/test_generic_contributor_merging.py index 1e58cc435..e669ec4fd 100644 --- a/tests/fortran/generic_interfaces/semantics/test_generic_contributor_merging.py +++ b/tests/fortran/generic_interfaces/semantics/test_generic_contributor_merging.py @@ -234,3 +234,78 @@ def test_a_procedure_local_generic_stays_inside_its_procedure(tmp_path: Path): ) assert [item.entity_kind for item in modules["facade_mod"].reexports if item.local_name == "convert"] == ["unknown"] + + +SAME_NAMED_SPECIFICS = """\ +module ints_mod + implicit none + interface convert + module procedure to_value + end interface +contains + integer function to_value(x) + integer, intent(in) :: x + to_value = x + end function to_value +end module ints_mod + +module reals_mod + implicit none + interface convert + module procedure to_value + end interface +contains + real function to_value(x) + real, intent(in) :: x + to_value = x + end function to_value +end module reals_mod + +module facade_mod + use ints_mod, only : convert + use reals_mod, only : convert + implicit none + interface convert + module procedure to_value_l + end interface +contains + logical function to_value_l(x) + logical, intent(in) :: x + to_value_l = x + end function to_value_l +end module facade_mod +""" + + +def test_contributors_spelling_a_specific_alike_stay_two_procedures(tmp_path: Path): + """A specific is identified by the module declaring it, not by its spelling. + + Two contributors each declare `to_value`. Keying them by name alone made + the second look like the first and dropped it, so the merged generic lost a + signature it must dispatch over. + """ + modules = _modules(tmp_path, SAME_NAMED_SPECIFICS) + overload_set = next(item for item in modules["facade_mod"].overload_sets if item.name == "convert") + + identities = [ + (procedure.origin.native_scope, procedure.arguments[0].semantic_type.name) + for procedure in overload_set.procedures + ] + assert identities == [("ints_mod", "Int32"), ("reals_mod", "Float32"), ("facade_mod", "Bool")] + + +def test_a_contract_names_each_merged_specific_distinctly(tmp_path: Path): + """Two specifics spelled alike need two Python names and two targets.""" + from prik.policy.exports import complete_python_export_policy + from prik.printers.pyi import PyiPrinter + + modules = _modules(tmp_path, SAME_NAMED_SPECIFICS) + facade = modules["facade_mod"] + complete_python_export_policy(facade) + contract = PyiPrinter(normalize_public_names=True).emit(facade) + + assert contract.count("def to_value(") == 1 + assert contract.count("def to_value_2(") == 1 + # Each dispatcher names the declaration this contract actually writes. + assert '@overload("to_value")' in contract + assert '@overload("to_value_2")' in contract From 812c934808464e5fd72db56265271aec03e395a8 Mon Sep 17 00:00:00 2001 From: said Date: Fri, 18 Sep 2026 07:11:58 +0100 Subject: [PATCH 67/96] Hold the names a module imports while naming its prototypes Settling a procedure-local prototype's contract spelling reserved the module's declared names first, so a suggestion could not take one. A use-associated name binds in the module too, and the contract writes an import for it, but it was not held -- so a module writing use helper_mod, only : first_cb whose contained procedure `first` declares `cb` produced a contract where from .helper_mod import first_cb @prototype def first_cb(...) bind the same name, the prototype shadowing the import. Hold what the module imports beside what it declares. The prototype takes the next spelling instead, and the annotation naming it follows, because both read the one settled name. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 5 ++ prik/semantics/fortran2ir.py | 14 ++++-- .../semantics/test_declaration_publication.py | 50 ++++++++++++++++++- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 776e8ff10..d8f1ee421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ release tags add a leading `v` to the package version. ## Unreleased +- A procedure-local prototype cannot take a name its module imports. Allocating + its contract spelling held only the module's declared names, so a module + importing `first_cb` and declaring `cb` inside `first` wrote a prototype that + shadowed the import the contract also writes. + - A merged generic keeps specifics that two contributing modules spell alike. Specific procedures were looked up by name alone, so a second contributor's `to_value` looked like the first and was dropped, losing a signature the diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 4aba828f9..06c78bab3 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -1143,6 +1143,7 @@ def _record_prototype_argument_intent( def _settle_prototype_contract_names( self, module: FortranModule, + index: dict[str, FortranModule], prototypes: list[SemanticPrototype], functions: list[SemanticFunction], classes: list[SemanticClass], @@ -1164,14 +1165,19 @@ def _settle_prototype_contract_names( # names a build publishes, which a prototype is not. naming = NamingPolicy(preserve_case=True) # A module's own block declares the name another module imports, so it - # keeps it; every other declared name is held first so no prototype can - # be handed a spelling that already belongs to one. + # keeps it; every other name the contract binds is held first so no + # prototype can be handed a spelling that already belongs to one. A + # use-associated name binds in this module too, and the contract writes + # an import for it, so it is held alongside the declared names. module_scope = { str(prototype.native_name or prototype.name).casefold() for prototype in prototypes if not prototype.declaring_scope } - for name in sorted(self._module_declared_names(module)): + held = self._module_declared_names(module) | { + name.casefold() for name in self._use_associated_names(module, index) + } + for name in sorted(held): if name in module_scope: continue naming.reserve_public_name((), name, category="function", owner=("declared", name)) @@ -1673,7 +1679,7 @@ def _visit_FortranModule( prototypes=prototypes, ), ) - self._settle_prototype_contract_names(module, prototypes, semantic_functions, semantic_classes) + self._settle_prototype_contract_names(module, index, prototypes, semantic_functions, semantic_classes) return SemanticModule( name=module.name, functions=semantic_functions, diff --git a/tests/fortran/modules/semantics/test_declaration_publication.py b/tests/fortran/modules/semantics/test_declaration_publication.py index 065c24a33..8c3f50b18 100644 --- a/tests/fortran/modules/semantics/test_declaration_publication.py +++ b/tests/fortran/modules/semantics/test_declaration_publication.py @@ -9,10 +9,10 @@ from pathlib import Path -from prik.parsers.fortran import parse_fortran_file +from prik.parsers.fortran import parse_fortran_file, parse_fortran_project from prik.printers.pyi import PyiPrinter from prik.policy.exports import complete_python_export_policy -from prik.semantics.fortran2ir import fortran_file_to_semantic_modules +from prik.semantics.fortran2ir import fortran_file_to_semantic_modules, fortran_project_to_semantic_modules PRIVATE_SOURCE = """\ module m @@ -237,3 +237,49 @@ def test_a_contract_writes_one_prototype_for_each_scope(tmp_path: Path): assert f"f: {local_name}" in contract assert "g: first_cb" in contract assert '__all__ = ["first_cb", "first", "uses_module_one"]' in contract + + +IMPORT_COLLISION_SOURCE = """\ +module helper_mod + implicit none + integer :: first_cb = 7 +end module helper_mod + +module m_mod + use helper_mod, only : first_cb + implicit none +contains + subroutine first(f) + abstract interface + subroutine cb(x) + real :: x + end subroutine + end interface + procedure(cb) :: f + call f(1.0) + end subroutine first +end module m_mod +""" + + +def test_a_prototype_does_not_take_a_name_the_module_imports(tmp_path: Path): + """A use-associated name binds here too, so a prototype cannot be given it. + + `m_mod` imports `first_cb`, and its contained procedure declares `cb`, + whose suggested spelling is the same. Allocating against the declared names + alone let the prototype shadow the import the contract writes. + """ + (tmp_path / "project.f90").write_text(IMPORT_COLLISION_SOURCE, encoding="utf-8") + modules = { + module.name: module for module in fortran_project_to_semantic_modules(parse_fortran_project(str(tmp_path))) + } + module = modules["m_mod"] + complete_python_export_policy(module) + + assert [(item.native_name, item.declaring_scope) for item in module.prototypes] == [("cb", ("first",))] + assert module.prototypes[0].name != "first_cb" + + contract = PyiPrinter(normalize_public_names=True).emit(module) + assert "from .helper_mod import first_cb" in contract + assert f"def {module.prototypes[0].name}(" in contract + assert f"f: {module.prototypes[0].name}" in contract From 8201022a13b82b146ff26bcb839ef8a6f1dce0a0 Mon Sep 17 00:00:00 2001 From: said Date: Fri, 18 Sep 2026 07:12:19 +0100 Subject: [PATCH 68/96] Read a stated export surface exactly, and an empty one as stated Two readings in export completion settled a decision instead of reading it. `__all__` was compared case-insensitively. A contract is Python, where `Foo` and `foo` are different names, so a list naming `Foo` beside a declaration written `foo` names something the module does not define -- and publishing `foo` for it invents a surface the contract never stated. And a declaration's export list was tested for truth, so an absent key and an explicit empty list were treated alike. They mean opposite things: no key means no stage has projected the declaration yet, and the default publication applies; an empty list means a stage decided it publishes nowhere, which substituting that default reverses. `_apply_source_python_exports()` writes exactly that empty list for a private declaration, so the two were only ever kept apart by a second guard reaching the same conclusion. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 7 +++ prik/policy/exports.py | 19 +++++-- .../test_contract_publication_round_trip.py | 50 ++++++++++++++++++- 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8f1ee421..a9efcde42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ release tags add a leading `v` to the package version. ## Unreleased +- A contract's `__all__` selects declarations by exact spelling, and a + declaration already projected to no Python namespace keeps that projection. + Names were compared case-insensitively, so `__all__ = ["Foo"]` published a + declaration written `foo` although Python names are case-sensitive; and an + empty export list read as "nothing decided yet", so a default publication + replaced a decision an earlier stage had taken. + - A procedure-local prototype cannot take a name its module imports. Allocating its contract spelling held only the module's declared names, so a module importing `first_cb` and declaring `cb` inside `first` wrote a prototype that diff --git a/prik/policy/exports.py b/prik/policy/exports.py index 25f6df07f..c94127b83 100644 --- a/prik/policy/exports.py +++ b/prik/policy/exports.py @@ -33,11 +33,18 @@ def _stated_export_names(module: models.SemanticModule) -> set[str] | None: """Return the surface one contract states, or ``None`` when it states none. A contract that writes no ``__all__`` publishes what it declares, so there - is nothing stated to read and every declaration is completed as before. + is nothing stated to read and every declaration is completed as before. An + empty list is a statement, not the absence of one: it says the module + publishes nothing. + + The names are compared exactly. A contract is Python, where ``Foo`` and + ``foo`` are different names, so a list naming ``Foo`` does not publish a + declaration written ``foo`` -- it names something the module does not + define. """ if module.exported_names is None: return None - return {str(name).casefold() for name in module.exported_names} + return {str(name) for name in module.exported_names} def complete_python_export_policy( @@ -68,11 +75,15 @@ def complete_python_export_policy( for owner in _module_export_owners(module): if getattr(owner, "visibility", "public") == "private": continue - if stated is not None and str(owner.name).casefold() not in stated: + if stated is not None and str(owner.name) not in stated: continue metadata = _owner_metadata(owner) exports = metadata.get(models.PYTHON_EXPORTS_METADATA) - if not exports: + if exports is None: + # No stage has projected this declaration yet, so it publishes + # itself in its own namespace. An empty list is not that: it is a + # stage having decided the declaration publishes nothing, and + # replacing it here would reverse that decision. exports = [{"namespace": (), "name": None}] metadata[models.PYTHON_EXPORTS_METADATA] = exports category = _owner_category(owner) diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py index ac0a19234..b5a84e5a0 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py @@ -13,7 +13,13 @@ from prik.pipeline.pyi import pyi_paths_to_semantic_modules from prik.policy.exports import complete_python_export_policy from prik.printers.pyi import PyiPrinter -from prik.semantics.models import PYTHON_EXPORTS_METADATA, ProcedureOverloadSet +from prik.semantics.models import ( + PYTHON_EXPORTS_METADATA, + ProcedureOverloadSet, + SemanticFunction, + SemanticModule, + SemanticOrigin, +) from prik.semantics.fortran2ir import fortran_file_to_semantic_modules from prik.parsers.fortran import parse_fortran_file @@ -110,3 +116,45 @@ def test_a_withheld_declaration_is_still_reachable_for_naming(generated_contract reloaded = pyi_paths_to_semantic_modules([generated_contract])[0] assert PyiPrinter().published_names(reloaded)["cb"] == "cb" + + +def test_a_stated_name_selects_a_declaration_by_exact_spelling(tmp_path: Path): + """A contract is Python, where `Foo` and `foo` are different names. + + A list naming `Foo` beside a declaration written `foo` names something the + module does not define, so it publishes nothing. + """ + contract = tmp_path / "cased_mod.pyi" + contract.write_text( + 'from prik.contracts import Int32\n\ndef foo(\n a: Int32\n) -> None: ...\n\n__all__ = ["Foo"]\n', + encoding="utf-8", + ) + module = pyi_paths_to_semantic_modules([contract])[0] + + complete_python_export_policy(module) + + assert [item.name for item in module.functions] == ["foo"] + assert module.functions[0].metadata.get(PYTHON_EXPORTS_METADATA) is None + + +def test_a_declaration_already_projected_to_nothing_keeps_that_decision(): + """An empty export list is a decision taken, not a missing one. + + A stage that has projected a declaration to no Python namespace records an + empty list. Reading that as "nothing decided yet" and substituting a + default publication would reverse it. + """ + withheld = SemanticFunction(name="withheld", native_name="withheld") + withheld.metadata[PYTHON_EXPORTS_METADATA] = [] + fresh = SemanticFunction(name="fresh", native_name="fresh") + module = SemanticModule( + name="mod", + functions=[withheld, fresh], + origin=SemanticOrigin(source_language="fortran", source_kind="module"), + ) + + complete_python_export_policy(module) + + assert withheld.metadata[PYTHON_EXPORTS_METADATA] == [] + # A declaration no stage has projected still publishes itself. + assert fresh.metadata[PYTHON_EXPORTS_METADATA] == [{"namespace": (), "name": "fresh"}] From 57425d24a1772ee116c424bb4ee1ba1c0894ee99 Mon Sep 17 00:00:00 2001 From: said Date: Fri, 18 Sep 2026 07:15:56 +0100 Subject: [PATCH 69/96] Bind a generic's private specifics in one place Identifying a specific by its declaring module left `_module_overload_sets()` one branch over the staged complexity limit. The rebinding it does for a constructor and for an ordinary generic is the same loop written twice, so it becomes one helper: each private specific is bound through the public name that reaches it -- the generic, or the type name for a constructor. No behavior changes; the two call sites already did exactly this. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- prik/semantics/fortran2ir.py | 46 ++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 06c78bab3..2c87b4aaf 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -3185,6 +3185,24 @@ def _bound_methods( ) return methods + @staticmethod + def _bind_private_specifics_through_generic( + overload_set: ProcedureOverloadSet, + targets: list[_SpecificProcedure], + lookup: dict[tuple[str, str], SemanticFunction], + generic_name: str, + ) -> None: + """Bind each private specific through the generic name that reaches it. + + A specific its declaring module keeps private is unreachable by its own + name, while the generic -- or, for a constructor, the type name -- is + public and resolves to the same procedure. + """ + for target, candidate in zip(targets, overload_set.procedures, strict=True): + if lookup[target.key].visibility == "private": + candidate.native_name = generic_name + candidate.metadata[BIND_TARGET_METADATA] = generic_name + def _module_overload_sets( self, module: FortranModule, @@ -3251,13 +3269,12 @@ 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 = own_lookup | inline_lookup | inherited_lookup - for target, candidate in zip(target_names, constructor_set.procedures, strict=True): - if target_lookup[target.key].visibility == "private": - # A private specific is unreachable by name; the type - # name is public and resolves to the same procedure. - candidate.native_name = interface.name - candidate.metadata[BIND_TARGET_METADATA] = interface.name + self._bind_private_specifics_through_generic( + constructor_set, + target_names, + own_lookup | inline_lookup | inherited_lookup, + interface.name, + ) self._merge_overload_sets(constructor_class.overload_sets, [constructor_set]) self._mark_constructor_specifics(procedures, procedure_lookup, interface.name) continue @@ -3269,11 +3286,12 @@ def _module_overload_sets( else module.name, visibility=self._symbol_visibility(module, interface.name), ) - target_lookup = own_lookup | inline_lookup | inherited_lookup - for target, candidate in zip(target_names, overload_set.procedures, strict=True): - if target_lookup[target.key].visibility == "private": - candidate.native_name = interface.name - candidate.metadata[BIND_TARGET_METADATA] = interface.name + self._bind_private_specifics_through_generic( + overload_set, + target_names, + own_lookup | inline_lookup | inherited_lookup, + interface.name, + ) overload_sets.append(overload_set) continue defined_sets = self._defined_overload_sets( @@ -3704,9 +3722,7 @@ def _generic_target_names( (str(item.origin.native_scope or "").casefold(), str(item.native_name or item.name).casefold()) for item in inherited_functions } - inherited_functions.extend( - inherited_lookup[target.key] for target in inherited if target.key not in known - ) + inherited_functions.extend(inherited_lookup[target.key] for target in inherited if target.key not in known) own = [ _SpecificProcedure(module.name, name) for name in (interface.specific_procedures or [signature.name for signature in interface.procedures]) From 33075484a3f4261a0845e6fc4a15b23fa895c563 Mon Sep 17 00:00:00 2001 From: said Date: Fri, 18 Sep 2026 09:55:52 +0100 Subject: [PATCH 70/96] Keep each use statement, and read them in one place `dict[str, list[FortranUseMapping]]` had run out of room. An empty list meant "imports everything", so `use m, only :` -- valid syntax that imports nothing -- could not be told from `use m`. `_record_use_mappings()` resolved the overlap by letting a bare `use` replace whatever an earlier statement listed, so `use m, only : p => q` followed by `use m` lost `p` altogether, though the language reads the two together. And putting `only` on each mapping, as the previous commit did, could not record a statement that has no mappings at all. Keep what the source says: `FortranUseStatement(module, only, mappings)`, one per statement, appended rather than merged. `FortranUseAssociation.of()` is the single reading of them -- what a scope sees under which name -- and every consumer asks it instead of deciding for itself what an empty list or a rename means. That deletes the interpretations rather than adding to them: `_carries_every_public_name()` and `_renamed_away_names()` are gone, and the `if not mappings` branches in route collection, public names, callback interfaces, declaration-call resolution, derived-type origins, contract imports and compile-time symbols are replaced by `imports_all` and `carried()`. Those last five had never been taught about non-`only` renames at all, so `use kinds, wp => rk` no longer loses the rest of what `kinds` offers. `fortran2ir.py` and `parser.py` both end up shorter than before. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 8 ++ prik/parsers/fortran/models.py | 93 +++++++++++-- prik/parsers/fortran/parser.py | 73 +++++----- prik/semantics/fortran2ir.py | 127 ++++++++---------- .../parsing/test_declarations_and_shapes.py | 3 +- .../semantics/test_types_and_storage.py | 7 +- .../test_imported_derived_semantics.py | 9 +- .../fixtures/general/module_vars_use.json | 40 +++--- .../test_declaration_and_interface_edges.py | 17 ++- ...ortran_parser_procedures_and_interfaces.py | 7 +- .../parsing/test_project_scope_models.py | 12 +- .../modules/parsing/test_scope_handling.py | 31 ++++- .../semantics/test_modules_and_imports.py | 13 +- .../semantics/test_reexport_accessibility.py | 77 +++++++++++ 14 files changed, 349 insertions(+), 168 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9efcde42..72ce346e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ release tags add a leading `v` to the package version. ## Unreleased +- Every legal `use` form is now represented, and several statements naming one + module are read together. `use m, only :` is valid and imports nothing, yet + was indistinguishable from a bare `use m`; `use m, only : p => q` followed by + `use m` discarded the first statement entirely; and a rename without `only` + still dropped every other name its module offered -- losing imported + compile-time symbols, callback interfaces and derived types along with them. + The parser keeps each statement as written and one reading interprets them. + - A contract's `__all__` selects declarations by exact spelling, and a declaration already projected to no Python namespace keeps that projection. Names were compared case-insensitively, so `__all__ = ["Foo"]` published a diff --git a/prik/parsers/fortran/models.py b/prik/parsers/fortran/models.py index a812140f6..ee49ceab3 100644 --- a/prik/parsers/fortran/models.py +++ b/prik/parsers/fortran/models.py @@ -15,6 +15,7 @@ import re import sys from dataclasses import dataclass, field +from collections.abc import Iterable, Mapping from typing import Any from prik.parsers.fortran.type_resolver import extract_character_selector @@ -347,15 +348,6 @@ class FortranUseMapping: source: str target: str | None = None - only: bool = True - """Whether the ``use`` listing this name narrowed to an ``only`` list. - - ``use m, only : x`` brings in ``x`` alone, while ``use m, p => q`` renames - one entity and still carries everything else the module offers. Both record - a mapping, so what separates them is kept here rather than inferred from a - list being non-empty. - """ - def __eq__(self, other: object) -> bool: if isinstance(other, str): return self.local_name == other @@ -377,7 +369,7 @@ class FortranProcedureSignature: result: FortranArgument | None = None attributes: list[str] = field(default_factory=list) bind_name: str | None = None - uses: dict[str, list[FortranUseMapping]] = field(default_factory=dict) + uses: dict[str, list[FortranUseStatement]] = field(default_factory=dict) in_interface: bool = False variables: dict[str, FortranVariable] = field(default_factory=dict) common_variables: list[str] = field(default_factory=list) @@ -435,11 +427,86 @@ class FortranEnum: visibility: str = "public" +@dataclass +class FortranUseStatement: + """One ``use`` statement exactly as the source writes it. + + ``only`` records whether the statement narrowed to an ``only`` list, which + is independent of what it listed: ``use m`` lists nothing and narrows + nothing, ``use m, only :`` lists nothing and narrows to nothing. Statements + are kept apart because the language reads several for one module together, + and combining them while parsing would lose what each one said. + """ + + module: str + only: bool = False + mappings: list[FortranUseMapping] = field(default_factory=list) + + +@dataclass(frozen=True) +class FortranUseAssociation: + """What one scope's ``use`` statements of a single module make visible. + + This is the one reading of those statements. Every consumer asks it what a + scope sees, rather than deciding for itself what an empty mapping list or a + rename means. + """ + + imports_all: bool = False + mappings: tuple[FortranUseMapping, ...] = () + + @classmethod + def of(cls, statements: Iterable[FortranUseStatement]) -> FortranUseAssociation: + """Combine every ``use`` statement naming one module into one reading. + + Each statement adds the names it lists, and any statement without + ``only`` makes the module's remaining public names accessible too. + """ + mappings: dict[tuple[str, str], FortranUseMapping] = {} + imports_all = False + for statement in statements: + imports_all = imports_all or not statement.only + for mapping in statement.mappings: + mappings.setdefault((mapping.source.casefold(), mapping.local_name.casefold()), mapping) + return cls(imports_all, tuple(mappings.values())) + + @property + def renamed_sources(self) -> frozenset[str]: + """Return the names a rename reaches, which are not reachable as written. + + ``use m, p => q`` accesses that entity as ``p``; ``q`` names nothing in + the importing scope. + """ + return frozenset(item.source.casefold() for item in self.mappings if item.target) + + def carried(self, offered: Mapping[str, Any]) -> dict[str, Any]: + """Return what this association brings in, keyed by its local name. + + ``offered`` maps each name the used module publishes to whatever the + caller tracks for it. A listed name arrives under the spelling it binds; + the rest arrive unchanged when the association imports all. + """ + carried: dict[str, Any] = {} + if self.imports_all: + renamed = self.renamed_sources + carried.update((name, value) for name, value in offered.items() if name not in renamed) + for mapping in self.mappings: + source = mapping.source.casefold() + if source in offered: + carried[mapping.local_name.casefold()] = offered[source] + return carried + + +def use_associations(uses: Mapping[str, Iterable[FortranUseStatement]]) -> dict[str, FortranUseAssociation]: + """Read one scope's whole import table as an association per used module.""" + return {module: FortranUseAssociation.of(statements) for module, statements in uses.items()} + + @dataclass class FortranModule: name: str filename: str | None = None - uses: dict[str, list[FortranUseMapping]] = field(default_factory=dict) + uses: dict[str, list[FortranUseStatement]] = field(default_factory=dict) variables: list[FortranVariable] = field(default_factory=list) procedures: list[FortranProcedureSignature] = field(default_factory=list) derived_types: list[FortranDerivedType] = field(default_factory=list) @@ -457,7 +524,7 @@ class FortranSubmodule: parent: str ancestor: str | None = None filename: str | None = None - uses: dict[str, list[FortranUseMapping]] = field(default_factory=dict) + uses: dict[str, list[FortranUseStatement]] = field(default_factory=dict) variables: list[FortranVariable] = field(default_factory=list) procedures: list[FortranProcedureSignature] = field(default_factory=list) derived_types: list[FortranDerivedType] = field(default_factory=list) @@ -470,7 +537,7 @@ class FortranSubmodule: class FortranProgram: name: str | None = None filename: str | None = None - uses: dict[str, list[FortranUseMapping]] = field(default_factory=dict) + uses: dict[str, list[FortranUseStatement]] = field(default_factory=dict) variables: list[FortranVariable] = field(default_factory=list) procedures: list[FortranProcedureSignature] = field(default_factory=list) enums: list[FortranEnum] = field(default_factory=list) diff --git a/prik/parsers/fortran/parser.py b/prik/parsers/fortran/parser.py index 35df11b83..3f624e3d3 100644 --- a/prik/parsers/fortran/parser.py +++ b/prik/parsers/fortran/parser.py @@ -42,6 +42,8 @@ FortranProgram, FortranProject, FortranSubmodule, + FortranUseStatement, + use_associations, FortranUseMapping, FortranVariable, ) @@ -3493,8 +3495,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 - self._record_use_mappings(target.uses, module_name, mappings) + self._record_use_mappings(target.uses, parsed_use) return if _REGEX["derived_type"].match(stripped): @@ -3661,9 +3662,8 @@ def _parse_procedure_spec_line( return parsed_use = self._parse_use_statement(stripped) if parsed_use: - module_name, mappings = parsed_use - self._record_use_mappings(proc_state.uses, module_name, mappings) - self._record_use_mappings(proc_state.local_uses, module_name, mappings) + self._record_use_mappings(proc_state.uses, parsed_use) + self._record_use_mappings(proc_state.local_uses, parsed_use) return # This parser is a subset parser focused on wrapper-relevant metadata. # These statements do not affect extracted signature typing/shapes. @@ -5231,7 +5231,7 @@ def _build_compile_time_symbols( @staticmethod def _imported_compile_time_symbols( - uses: Mapping[str, list[FortranUseMapping]], + uses: Mapping[str, list[FortranUseStatement]], symbols: _CompileTimeSymbols, *, include_intrinsic_aliases: bool, @@ -5247,23 +5247,16 @@ def _imported_compile_time_symbols( lookup leaves that target-dependent spelling untouched. """ imported: dict[str, str] = {} - for dependency, mappings in uses.items(): + for dependency, association in use_associations(uses).items(): dependency_name = dependency.casefold() dependency_symbols = symbols.in_module(dependency_name) - if not mappings: - imported.update(dependency_symbols) + imported.update(association.carried(dependency_symbols)) + if not include_intrinsic_aliases or dependency_name not in _INTRINSIC_COMPILE_TIME_MODULES: continue - for mapping in mappings: - source_name = mapping.source.casefold() - expression = dependency_symbols.get(source_name) - if ( - expression is None - and include_intrinsic_aliases - and dependency_name in _INTRINSIC_COMPILE_TIME_MODULES - ): - expression = mapping.source - if expression is not None: - imported[mapping.local_name.casefold()] = expression + # An intrinsic module has no parsed symbols, so a name imported + # from one stands for its own target-dependent spelling. + for mapping in association.mappings: + imported.setdefault(mapping.local_name.casefold(), mapping.source) return imported @staticmethod @@ -5823,37 +5816,31 @@ def _bind_c_name(tail: str) -> str | None: @staticmethod def _record_use_mappings( - uses: dict[str, list[FortranUseMapping]], - module_name: str, - mappings: list[FortranUseMapping], + uses: dict[str, list[FortranUseStatement]], + statement: FortranUseStatement, ) -> None: - """Accumulate one ``use`` statement into a scope's import table. + """Append one ``use`` statement to 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. + A scope may name the same module more than once, and what each + statement said is a source fact, so they are kept apart here and read + together by ``FortranUseAssociation``. """ - 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 - ) + uses.setdefault(statement.module, []).append(statement) @staticmethod - def _parse_use_statement(line: str) -> tuple[str, list[FortranUseMapping]] | None: - """Parse a ``use`` statement into its module and explicit mappings.""" + def _parse_use_statement(line: str) -> FortranUseStatement | None: + """Parse one ``use`` statement into the facts the source states. + + Whether the statement narrowed to an ``only`` list is separate from + what it listed: ``use m, only :`` lists nothing and brings in nothing, + while ``use m`` also lists nothing and brings in everything. + """ match = _REGEX["use"].match(line) if not match: return None rest = (match.group("rest") or "").strip() if not rest: - return match.group("module"), [] + return FortranUseStatement(match.group("module")) payload = rest.lstrip(",").strip() only_match = re.match(r"^only\s*:\s*(?P.*)$", payload, re.IGNORECASE) if only_match: @@ -5868,8 +5855,8 @@ def _parse_use_statement(line: str) -> tuple[str, list[FortranUseMapping]] | Non else: source = token target = None - mappings.append(FortranUseMapping(source=source, target=target, only=only_match is not None)) - return match.group("module"), mappings + mappings.append(FortranUseMapping(source=source, target=target)) + return FortranUseStatement(match.group("module"), only_match is not None, mappings) # ----------------------------------------------------------------------------- diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 2c87b4aaf..a02559be5 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -35,7 +35,8 @@ FortranProgram, FortranProcedureSignature, FortranSubmodule, - FortranUseMapping, + FortranUseStatement, + use_associations, FortranVariable, ) from prik.utilities.declaration_expressions import ( @@ -243,8 +244,8 @@ class _DerivedTypeContext: """ module: str | None = None - uses: dict[str, list[FortranUseMapping]] | None = None - procedure_uses: dict[str, list[FortranUseMapping]] | None = None + uses: dict[str, list[FortranUseStatement]] | None = None + procedure_uses: dict[str, list[FortranUseStatement]] | None = None local_types: frozenset[str] = frozenset() @@ -268,7 +269,7 @@ class _DeclarationCallableContext: module: str | None local_procedures: dict[str, SemanticFunction] local_interfaces: dict[str, SemanticPrototype] - uses: dict[str, list[FortranUseMapping]] + uses: dict[str, list[FortranUseStatement]] def _normalize_compile_time_values( @@ -820,7 +821,7 @@ def _module_callback_interfaces( def _scope_callback_interfaces( cls, modules: dict[str, FortranModule], - uses: dict[str, list[FortranUseMapping]], + uses: dict[str, list[FortranUseStatement]], *, base: dict[str, _CallbackInterface], owner: FortranModule | None = None, @@ -877,7 +878,7 @@ def _merge_imported_callback_interfaces( cls, visible: dict[str, _CallbackInterface], modules: dict[str, FortranModule], - uses: dict[str, list[FortranUseMapping]], + uses: dict[str, list[FortranUseStatement]], *, seen: frozenset[str], override: bool, @@ -894,10 +895,10 @@ def _merge_imported_callback_interfaces( """ declared_here = set(visible) candidates: dict[str, set[tuple[str | None, str] | None]] = {} - for module_name, mappings in uses.items(): + for module_name, association in use_associations(uses).items(): source_module = modules.get(module_name.casefold()) if source_module is None: - for mapping in mappings: + for mapping in association.mappings: candidates.setdefault(mapping.local_name.casefold(), set()).add(None) continue source_lookup = cls._module_callback_interfaces( @@ -906,15 +907,13 @@ def _merge_imported_callback_interfaces( seen=seen, exported_only=True, ) - imported = ( - source_lookup - if not mappings - else { - 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 - } - ) + imported = association.carried(source_lookup) + # A rename binds the interface under the name the importing scope + # gives it, which a contract written here has to state. + for mapping in association.mappings: + local = mapping.local_name.casefold() + if local in imported: + imported[local] = replace(imported[local], local_name=mapping.local_name) for name, resolved in imported.items(): candidates.setdefault(name, set()).add(cls._callback_identity(resolved)) if override: @@ -1954,18 +1953,15 @@ def _module_public_names( seen = seen | {key} is_public = cls._effective_accessibility(module) offered: dict[str, set[str]] = {name: set() for name in cls._module_declared_names(module)} - for module_name, mappings in module.uses.items(): - for mapping in mappings: + for module_name, association in use_associations(module.uses).items(): + for mapping in association.mappings: offered.setdefault(mapping.local_name.casefold(), set()).add(module_name) - if not cls._carries_every_public_name(mappings): - continue used = index.get(module_name.casefold()) if used is None: continue - renamed_away = cls._renamed_away_names(mappings) - for name in cls._module_public_names(used, index, seen): - if name not in renamed_away: - offered.setdefault(name, set()).add(module_name) + reachable = dict.fromkeys(cls._module_public_names(used, index, seen), True) + for name in association.carried(reachable): + offered.setdefault(name, set()).add(module_name) return {name for name, routes in offered.items() if is_public(name, routes)} @staticmethod @@ -1991,25 +1987,6 @@ def _reconcile_routes(origins: list[tuple[str, str, str]]) -> tuple[str, str, st return distinct[0] return None - @staticmethod - def _carries_every_public_name(mappings: list[FortranUseMapping]) -> bool: - """Return whether one ``use`` brings in more than the names it lists. - - Only an ``only`` list narrows a ``use``. A bare ``use`` carries every - public name, and so does one that merely renames: ``use m, p => q`` - binds ``p`` and still carries everything else ``m`` offers. - """ - return not mappings or any(not mapping.only for mapping in mappings) - - @staticmethod - def _renamed_away_names(mappings: list[FortranUseMapping]) -> set[str]: - """Return the names a rename makes unreachable under their own spelling. - - ``use m, p => q`` accesses that entity as ``p``; ``q`` does not name it - here, so the carried set excludes it. - """ - return {mapping.source.casefold() for mapping in mappings if mapping.target} - @classmethod def _name_routes( cls, @@ -2030,12 +2007,12 @@ def _name_routes( folded = local_name.casefold() routes: list[_NameRoute] = [ _NameRoute(used_name, mapping.source) - for used_name, mappings in module.uses.items() - for mapping in mappings + for used_name, association in use_associations(module.uses).items() + for mapping in association.mappings if mapping.local_name.casefold() == folded ] - for used_name, mappings in module.uses.items(): - if not cls._carries_every_public_name(mappings) or folded in cls._renamed_away_names(mappings): + for used_name, association in use_associations(module.uses).items(): + if not association.imports_all or folded in association.renamed_sources: continue used = index.get(used_name.casefold()) if used is not None and folded in cls._module_public_names(used, index): @@ -2056,19 +2033,17 @@ def _use_associated_names( ways keeps the case its ``use`` statement wrote. """ names: dict[str, str] = {} - for mappings in module.uses.values(): - for mapping in mappings: + associations = use_associations(module.uses) + for association in associations.values(): + for mapping in association.mappings: names.setdefault(mapping.local_name.casefold(), mapping.local_name) - for used_name, mappings in module.uses.items(): - if not cls._carries_every_public_name(mappings): - continue + for used_name, association in associations.items(): used = index.get(used_name.casefold()) if used is None: continue - renamed_away = cls._renamed_away_names(mappings) - for name in sorted(cls._module_public_names(used, index)): - if name not in renamed_away: - names.setdefault(name, name) + offered = dict.fromkeys(sorted(cls._module_public_names(used, index)), True) + for name in association.carried(offered): + names.setdefault(name, name) return tuple(names.values()) def _module_reexports( @@ -2184,16 +2159,23 @@ def _declared_entity_kind(declaring: FortranModule | None, source_name: str) -> @staticmethod def _module_imports(module: FortranModule) -> list[str | SemanticImport]: - """Translate parser ``use`` mappings while preserving parser declaration order.""" + """Translate each ``use`` association while preserving declaration order. + + An association that lists names records them; one that also imports all + records the module itself beside them, which is what a bare ``use`` + means on its own. + """ imports: list[str | SemanticImport] = [] - for module_name, mappings in module.uses.items(): - if not mappings: + for module_name, association in use_associations(module.uses).items(): + if association.imports_all: imports.append(module_name) - else: + if association.mappings: imports.append( SemanticImport( module=module_name, - items=[SemanticImportItem(source=item.source, target=item.target) for item in mappings], + items=[ + SemanticImportItem(source=item.source, target=item.target) for item in association.mappings + ], ) ) return imports @@ -2204,7 +2186,7 @@ def _declaration_callable_context( functions: Iterable[SemanticFunction] = (), prototypes: Iterable[SemanticPrototype] = (), *, - uses: dict[str, list[FortranUseMapping]] | None = None, + uses: dict[str, list[FortranUseStatement]] | None = None, ) -> _DeclarationCallableContext: """Build lexical procedure facts for one module-owned declaration.""" return _DeclarationCallableContext( @@ -2289,8 +2271,8 @@ def _resolve_declaration_callable( explicit = [ (module_name, mapping.source) - for module_name, mappings in context.uses.items() - for mapping in mappings + for module_name, association in use_associations(context.uses).items() + for mapping in association.mappings if mapping.local_name.casefold() == key ] if len(explicit) == 1: @@ -2304,7 +2286,9 @@ def _resolve_declaration_callable( if explicit: return None - wildcard_modules = [module_name for module_name, mappings in context.uses.items() if not mappings] + wildcard_modules = [ + name for name, association in use_associations(context.uses).items() if association.imports_all + ] known_origins = [ module_name for module_name in wildcard_modules if (module_name.casefold(), key) in self._known_procedures ] @@ -2487,7 +2471,7 @@ def _procedure_derived_type_context( def _procedure_local_uses( proc: FortranProcedureSignature, parent: _DerivedTypeContext | None, - ) -> dict[str, list[FortranUseMapping]]: + ) -> dict[str, list[FortranUseStatement]]: """Return imports introduced locally by ``proc`` relative to its parent. A parser-preserved ``_local_uses`` mapping takes precedence; otherwise @@ -2564,7 +2548,7 @@ def _resolve_derived_type_origin( def _resolve_derived_type_origin_from_uses( self, local_name: str, - uses: dict[str, list[FortranUseMapping]] | None, + uses: dict[str, list[FortranUseStatement]] | None, ) -> _ResolvedDerivedTypeOrigin: """Resolve one derived-type spelling from explicit or wildcard ``use`` maps. @@ -2575,11 +2559,10 @@ def _resolve_derived_type_origin_from_uses( lname = local_name.lower() explicit: list[tuple[str, str]] = [] wildcard_modules: list[str] = [] - for module_name, mappings in (uses or {}).items(): - if not mappings: + for module_name, association in use_associations(uses or {}).items(): + if association.imports_all: wildcard_modules.append(module_name) - continue - for mapping in mappings: + for mapping in association.mappings: if mapping.local_name.lower() == lname: explicit.append((module_name, mapping.source)) diff --git a/tests/fortran/data_types/parsing/test_declarations_and_shapes.py b/tests/fortran/data_types/parsing/test_declarations_and_shapes.py index 6efa2da40..b230eb8f7 100644 --- a/tests/fortran/data_types/parsing/test_declarations_and_shapes.py +++ b/tests/fortran/data_types/parsing/test_declarations_and_shapes.py @@ -2,6 +2,7 @@ import pytest from prik.parsers.fortran import parse_fortran_file, parse_fortran_project +from prik.parsers.fortran.models import FortranUseAssociation from tests.fortran._support.parser_procedures import ( COMPILE_TIME_EXPRESSION_SOURCE, collect_project_procedure_signatures, @@ -136,7 +137,7 @@ def test_module_variables_and_use_statements(): assert len(modules) == 1 mod = modules[0] assert mod.name == "cfg" - assert mod.uses["iso_c_binding"] == ["c_int"] + assert list(FortranUseAssociation.of(mod.uses["iso_c_binding"]).mappings) == ["c_int"] assert [v.name for v in mod.variables] == ["nmax", "origin"] assert mod.variables[0].is_parameter is True assert mod.variables[1].is_parameter is False 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 054f1f10c..19e91b07e 100644 --- a/tests/fortran/data_types/semantics/test_types_and_storage.py +++ b/tests/fortran/data_types/semantics/test_types_and_storage.py @@ -10,6 +10,7 @@ FortranProcedureSignature, FortranProject, FortranUseMapping, + FortranUseStatement, FortranVariable, ) from prik.semantics.fortran2ir import ( @@ -44,8 +45,10 @@ def test_converter_visitor_and_compatibility_methods_cover_public_paths(): module = FortranModule( name="m", uses={ - "iso_c_binding": [FortranUseMapping(source="c_int", target="i32")], - "plain_import": [], + "iso_c_binding": [ + FortranUseStatement("iso_c_binding", True, [FortranUseMapping(source="c_int", target="i32")]) + ], + "plain_import": [FortranUseStatement("plain_import")], }, variables=[scale], procedures=[proc], diff --git a/tests/fortran/derived_types/semantics/test_imported_derived_semantics.py b/tests/fortran/derived_types/semantics/test_imported_derived_semantics.py index 23927f01a..e040e3408 100644 --- a/tests/fortran/derived_types/semantics/test_imported_derived_semantics.py +++ b/tests/fortran/derived_types/semantics/test_imported_derived_semantics.py @@ -10,6 +10,7 @@ FortranProcedureSignature, FortranProject, FortranUseMapping, + FortranUseStatement, FortranVariable, ) from prik.semantics.fortran2ir import ( @@ -47,8 +48,10 @@ def test_converter_preserves_imported_derived_contexts_through_dispatch_paths(): module = FortranModule( name="consumer", uses={ - "plain_mod": [], - "types_mod": [FortranUseMapping(source="state_t", target="local_state")], + "plain_mod": [FortranUseStatement("plain_mod")], + "types_mod": [ + FortranUseStatement("types_mod", True, [FortranUseMapping(source="state_t", target="local_state")]) + ], }, variables=[FortranVariable(name="module_state", base_type="derived", kind="local_state")], procedures=[proc], @@ -142,7 +145,7 @@ def test_abstract_type_identity_is_module_qualified_and_available_project_wide() ) consumer = FortranModule( name="consumer", - uses={"abstract_owner": [FortranUseMapping(source="item_t")]}, + uses={"abstract_owner": [FortranUseStatement("abstract_owner", True, [FortranUseMapping(source="item_t")])]}, procedures=[ FortranProcedureSignature( name="consume", diff --git a/tests/fortran/infrastructure/parsing/fixtures/general/module_vars_use.json b/tests/fortran/infrastructure/parsing/fixtures/general/module_vars_use.json index 3e2cf01d9..11719e60f 100644 --- a/tests/fortran/infrastructure/parsing/fixtures/general/module_vars_use.json +++ b/tests/fortran/infrastructure/parsing/fixtures/general/module_vars_use.json @@ -10,14 +10,18 @@ "uses": { "iso_c_binding": [ { - "source": "c_int", - "target": null, - "only": true - }, - { - "source": "c_double", - "target": null, - "only": true + "module": "iso_c_binding", + "only": true, + "mappings": [ + { + "source": "c_int", + "target": null + }, + { + "source": "c_double", + "target": null + } + ] } ] }, @@ -97,14 +101,18 @@ "uses": { "iso_c_binding": [ { - "source": "c_int", - "target": null, - "only": true - }, - { - "source": "c_double", - "target": null, - "only": true + "module": "iso_c_binding", + "only": true, + "mappings": [ + { + "source": "c_int", + "target": null + }, + { + "source": "c_double", + "target": null + } + ] } ] }, diff --git a/tests/fortran/infrastructure/parsing/test_declaration_and_interface_edges.py b/tests/fortran/infrastructure/parsing/test_declaration_and_interface_edges.py index 8c407d474..5e498a79f 100644 --- a/tests/fortran/infrastructure/parsing/test_declaration_and_interface_edges.py +++ b/tests/fortran/infrastructure/parsing/test_declaration_and_interface_edges.py @@ -3,6 +3,7 @@ import pytest from prik.parsers.fortran.models import FortranModule +from prik.parsers.fortran.models import FortranUseAssociation from prik.parsers.fortran.parser import FortranParser, _ParserScope from prik.parsers.fortran import FortranParseError, parse_fortran_file, parse_fortran_project @@ -137,11 +138,15 @@ def test_use_rename_and_intrinsic_forms_are_recorded(): module = parse_fortran_file(code).modules[0] - assert module.uses["list_input"] == ["delete_input"] - assert module.uses["list_input"][0].source == "delete_input_list" - assert module.uses["list_input"][0].target == "delete_input" - assert module.uses["iso_c_binding"] == ["c_int", "c_double"] - assert [(item.source, item.target) for item in module.uses["iso_c_binding"]] == [ + renamed = FortranUseAssociation.of(module.uses["list_input"]) + # A rename without `only` binds the new name and still imports the rest. + assert renamed.imports_all is True + assert list(renamed.mappings) == ["delete_input"] + assert (renamed.mappings[0].source, renamed.mappings[0].target) == ("delete_input_list", "delete_input") + intrinsic = FortranUseAssociation.of(module.uses["iso_c_binding"]) + assert intrinsic.imports_all is False + assert list(intrinsic.mappings) == ["c_int", "c_double"] + assert [(item.source, item.target) for item in intrinsic.mappings] == [ ("c_int", None), ("c_double", None), ] @@ -319,7 +324,7 @@ def test_use_statement_empty_only_items_are_ignored(): module = parse_fortran_file(code, filename="use_empty_items.f90").modules[0] - assert [item.local_name for item in module.uses["constants_mod"]] == ["rk", "ik"] + assert [item.local_name for item in FortranUseAssociation.of(module.uses["constants_mod"]).mappings] == ["rk", "ik"] def test_type_field_spec_variants_and_empty_entities_from_public_source(): diff --git a/tests/fortran/infrastructure/parsing/test_fortran_parser_procedures_and_interfaces.py b/tests/fortran/infrastructure/parsing/test_fortran_parser_procedures_and_interfaces.py index 0368eea6f..c12fca8e7 100644 --- a/tests/fortran/infrastructure/parsing/test_fortran_parser_procedures_and_interfaces.py +++ b/tests/fortran/infrastructure/parsing/test_fortran_parser_procedures_and_interfaces.py @@ -3,6 +3,7 @@ import pytest from prik.parsers.fortran import parse_fortran_file, parse_fortran_project from prik.parsers.fortran.models import ( + FortranUseAssociation, FortranFunctionCall, FortranSlice, FortranVariable, @@ -62,7 +63,7 @@ def test_function_result_and_use_statement(): assert sig.result is not None assert sig.result.name == "res" assert sig.result.base_type == "real" - assert sig.uses["iso_c_binding"] == ["c_double"] + assert list(FortranUseAssociation.of(sig.uses["iso_c_binding"]).mappings) == ["c_double"] assert sig.arguments[0].shape == [":"] @@ -447,14 +448,14 @@ def test_submodule_module_procedure_stub_and_additional_program_units(): submodule = submodules[0] assert submodule.parent == "parent_impl" assert submodule.ancestor == "ancestor_mod" - assert submodule.uses["iso_c_binding"] == ["c_int"] + assert list(FortranUseAssociation.of(submodule.uses["iso_c_binding"]).mappings) == ["c_int"] assert [v.name for v in submodule.variables] == ["counter"] assert [(p.name, p.kind) for p in submodule.procedures] == [("reset_counter", "module procedure")] programs = parse_fortran_programs(code) assert len(programs) == 1 assert programs[0].name == "driver" - assert programs[0].uses["ancestor_mod"] == [] + assert FortranUseAssociation.of(programs[0].uses["ancestor_mod"]).imports_all is True assert [v.name for v in programs[0].variables] == ["ierr"] block_data = parse_fortran_block_data(code) diff --git a/tests/fortran/modules/parsing/test_project_scope_models.py b/tests/fortran/modules/parsing/test_project_scope_models.py index 8a00c141c..d3b29490d 100644 --- a/tests/fortran/modules/parsing/test_project_scope_models.py +++ b/tests/fortran/modules/parsing/test_project_scope_models.py @@ -3,6 +3,7 @@ import pytest from prik.parsers.fortran import FortranParseError, parse_fortran_file, parse_fortran_project +from prik.parsers.fortran.models import FortranUseAssociation from prik.parsers.fortran.parser import FortranParser @@ -190,7 +191,7 @@ def test_program_contains_and_unnamed_block_data_public_models(): parsed = parse_fortran_file(code, filename="units.f90") - assert parsed.programs[0].uses["callback_mod"] == [] + assert FortranUseAssociation.of(parsed.programs[0].uses["callback_mod"]).imports_all is True assert [var.name for var in parsed.programs[0].variables] == ["ierr"] assert parsed.block_data_units[0].name is None assert [var.name for var in parsed.block_data_units[0].variables] == ["seed"] @@ -320,7 +321,9 @@ def test_directory_project_tracks_renamed_kind_imports_from_other_files(tmp_path assert args["x"].kind == "8" assert args["x"].shape == ["1:stride"] assert args["y"].kind == "16" - assert [(mapping.source, mapping.target) for mapping in proc.uses["precision_mod"]] == [ + assert [ + (mapping.source, mapping.target) for mapping in FortranUseAssociation.of(proc.uses["precision_mod"]).mappings + ] == [ ("wp", "local_wp"), ("stride", None), ("wide", "local_wide"), @@ -567,4 +570,7 @@ def test_project_resolution_uses_file_level_use_only_and_local_parameters(tmp_pa assert args["x"].kind == "selected_real_kind(12)" assert args["x"].shape == ["1:n"] assert args["y"].kind == "selected_real_kind(6)" - assert [mapping.local_name for mapping in proc.uses["public_params_mod"]] == ["rk", "n"] + assert [mapping.local_name for mapping in FortranUseAssociation.of(proc.uses["public_params_mod"]).mappings] == [ + "rk", + "n", + ] diff --git a/tests/fortran/modules/parsing/test_scope_handling.py b/tests/fortran/modules/parsing/test_scope_handling.py index 8f926e1d5..385edbdfb 100644 --- a/tests/fortran/modules/parsing/test_scope_handling.py +++ b/tests/fortran/modules/parsing/test_scope_handling.py @@ -2,6 +2,7 @@ from prik.parsers.fortran.models import FortranParseError from prik.parsers.fortran import parse_fortran_file +from prik.parsers.fortran.models import FortranUseAssociation def test_same_argument_name_in_different_procedures_is_allowed(): @@ -208,7 +209,8 @@ def test_repeated_use_of_one_module_accumulates_its_imports(): """ ).modules[0] - assert [(item.source, item.target) for item in module.uses["iso_fortran_env"]] == [ + association = FortranUseAssociation.of(module.uses["iso_fortran_env"]) + assert [(item.source, item.target) for item in association.mappings] == [ ("INT32", None), ("REAL32", "SP"), ("REAL64", "DP"), @@ -217,8 +219,12 @@ def test_repeated_use_of_one_module_accumulates_its_imports(): ] -def test_a_bare_use_absorbs_the_named_imports_of_the_same_module(): - """Importing everything subsumes any list beside it.""" +def test_a_bare_use_is_read_beside_the_named_imports_of_the_same_module(): + """Importing everything does not erase what another statement listed. + + Both statements are source facts, and the language reads them together: the + module's public names are accessible, and `rk` is bound as well. + """ module = parse_fortran_file( """ module wide_mod @@ -229,4 +235,21 @@ def test_a_bare_use_absorbs_the_named_imports_of_the_same_module(): """ ).modules[0] - assert module.uses["kinds_mod"] == [] + association = FortranUseAssociation.of(module.uses["kinds_mod"]) + assert association.imports_all is True + assert [(item.source, item.target) for item in association.mappings] == [("rk", None)] + + +def test_an_empty_only_list_imports_nothing(): + """`use m, only :` is valid and narrows to no names at all.""" + module = parse_fortran_file( + """ +module narrow_mod + use kinds_mod, only : + implicit none +end module narrow_mod +""" + ).modules[0] + + association = FortranUseAssociation.of(module.uses["kinds_mod"]) + assert (association.imports_all, association.mappings) == (False, ()) diff --git a/tests/fortran/modules/semantics/test_modules_and_imports.py b/tests/fortran/modules/semantics/test_modules_and_imports.py index 33601bd02..2b118c2cf 100644 --- a/tests/fortran/modules/semantics/test_modules_and_imports.py +++ b/tests/fortran/modules/semantics/test_modules_and_imports.py @@ -1,6 +1,7 @@ """Tests split by stable ownership concept from `test_compile_time_values.py`.""" from prik.parsers.fortran.models import ( + FortranUseStatement, FortranArgument, FortranModule, ) @@ -24,14 +25,22 @@ def test_converter_normalizes_wrapped_types_and_resolves_wildcard_imports(): converter = FortranToIRConverter(wrapped_derived_types={("types_mod", "state_t")}) - module = FortranModule(name="consumer", uses={"OTHER_MOD": [], "TYPES_MOD": []}) + module = FortranModule( + name="consumer", + uses={ + "OTHER_MOD": [FortranUseStatement("OTHER_MOD")], + "TYPES_MOD": [FortranUseStatement("TYPES_MOD")], + }, + ) context = converter._module_derived_type_context(module) state = converter.visit( FortranArgument(name="state", base_type="derived", kind="state_t"), derived_type_context=context, ).semantic_type - opaque_context = converter._module_derived_type_context(FortranModule(name="consumer", uses={"OPAQUE_MOD": []})) + opaque_context = converter._module_derived_type_context( + FortranModule(name="consumer", uses={"OPAQUE_MOD": [FortranUseStatement("OPAQUE_MOD")]}) + ) opaque = converter.visit( FortranArgument(name="opaque", base_type="derived", kind="opaque_t"), derived_type_context=opaque_context, diff --git a/tests/fortran/modules/semantics/test_reexport_accessibility.py b/tests/fortran/modules/semantics/test_reexport_accessibility.py index f68712359..df950b000 100644 --- a/tests/fortran/modules/semantics/test_reexport_accessibility.py +++ b/tests/fortran/modules/semantics/test_reexport_accessibility.py @@ -931,3 +931,80 @@ def test_a_renamed_entity_is_not_also_carried_under_its_own_name(tmp_path: Path) ) assert {item.local_name for item in modules["b_mod"].reexports} == {"p"} + + +def test_an_empty_only_list_carries_no_name(tmp_path: Path): + """`use m, only :` is valid syntax that narrows to nothing. + + It lists no names, exactly as a bare `use` does, so a model that cannot + tell the two apart reads one of them wrongly. + """ + modules = _project_modules( + tmp_path, + TRANSITIVE_DECLARING, + """\ +module b_mod + use a_mod, only : + implicit none +end module b_mod +""", + ) + + assert modules["b_mod"].reexports == [] + + +def test_statements_naming_one_module_are_read_together(tmp_path: Path): + """The language combines them, so neither statement erases the other.""" + modules = _project_modules( + tmp_path, + """\ +module a_mod + implicit none + integer :: q = 1 + integer :: other = 2 +end module a_mod + +module b_mod + use a_mod, only : p => q + use a_mod + implicit none +end module b_mod +""", + ) + + reexports = {item.local_name: (item.origin_module, item.source_name) for item in modules["b_mod"].reexports} + assert reexports == {"p": ("a_mod", "q"), "other": ("a_mod", "other")} + + +def test_a_non_only_rename_still_carries_imported_compile_time_symbols(tmp_path: Path): + """Every consumer reads the same association, not just route resolution. + + `use kinds_mod, wp => rk` binds `wp` and still imports `nmax`, which a + declaration's extent needs resolved. + """ + (tmp_path / "project.f90").write_text( + """\ +module kinds_mod + implicit none + integer, parameter :: rk = 8 + integer, parameter :: nmax = 4 +end module kinds_mod + +module use_mod + use kinds_mod, wp => rk + implicit none + real(wp) :: values(nmax) +end module use_mod +""", + encoding="utf-8", + ) + project = parse_fortran_project(str(tmp_path)) + declared = next( + variable + for parsed in project.files + for module in parsed.modules + if module.name == "use_mod" + for variable in module.variables + ) + + assert (declared.kind, declared.shape) == ("8", ["4"]) From 112de90645e10805b726646c64015ef685dfe299 Mon Sep 17 00:00:00 2001 From: said Date: Fri, 18 Sep 2026 09:57:13 +0100 Subject: [PATCH 71/96] Finish structural identity for a generic's specifics Making a specific's identity structural left `_bound_overload_sets()` passing its now tuple-keyed lookup to two helpers that still asked for a bare name. `_apply_assignment_projection_to_originals()` therefore found nothing, so a type-bound generic :: assignment(=) => assign_value projected its bound object as the result on the generic's candidate while `assign_value` itself kept the unprojected signature -- the same call behaving differently through the two names. The module-level path happened to work only because it was handed the old string-keyed map. Finish the migration rather than teach the helpers two key shapes. One reading, `_declared_specific()`, derives a specific's identity from the declaration it came from, and both helpers and both call sites use it. Nothing is left that has to guess how its lookup is keyed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 6 ++++ prik/semantics/fortran2ir.py | 20 +++++++---- .../test_generic_contributor_merging.py | 36 +++++++++++++++++++ 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72ce346e1..629fadb2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ release tags add a leading `v` to the package version. ## Unreleased +- A type-bound defined assignment updates the method it names. Making a + specific's identity structural left two helpers looking the original up by + bare name, so `generic :: assignment(=) => assign_value` projected its bound + object on the generic's candidate while the method itself kept the + unprojected signature. + - Every legal `use` form is now represented, and several statements naming one module are read together. `use m, only :` is valid and imports nothing, yet was indistinguishable from a bare `use m`; `use m, only : p => q` followed by diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index a02559be5..5db216f9b 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -3168,6 +3168,14 @@ def _bound_methods( ) return methods + @staticmethod + def _declared_specific(procedure: SemanticFunction) -> tuple[str, str]: + """Return the identity of the declaration one specific was taken from.""" + return _SpecificProcedure( + str(procedure.origin.native_scope or ""), + str(procedure.native_name or procedure.name), + ).key + @staticmethod def _bind_private_specifics_through_generic( overload_set: ProcedureOverloadSet, @@ -3259,7 +3267,7 @@ def _module_overload_sets( interface.name, ) self._merge_overload_sets(constructor_class.overload_sets, [constructor_set]) - self._mark_constructor_specifics(procedures, procedure_lookup, interface.name) + self._mark_constructor_specifics(procedures, own_lookup, interface.name) continue overload_set = self._normal_overload_set( interface.name, @@ -3282,7 +3290,7 @@ def _module_overload_sets( procedures, class_map, ) - self._apply_assignment_projection_to_originals(interface.name, procedures, procedure_lookup, class_map) + self._apply_assignment_projection_to_originals(interface.name, procedures, own_lookup, class_map) for semantic_class, class_sets in defined_sets: self._merge_overload_sets(semantic_class.overload_sets, class_sets) return overload_sets, inherited_functions @@ -3344,7 +3352,7 @@ def _apply_assignment_projection_to_originals( self, generic_name: str, procedures: list[SemanticFunction], - lookup: dict[str, SemanticFunction], + lookup: dict[tuple[str, str], SemanticFunction], classes: dict[str, SemanticClass], ) -> None: """Replace valid defined-assignment projections on their original procedures. @@ -3359,14 +3367,14 @@ def _apply_assignment_projection_to_originals( for procedure in procedures: if self._defined_procedure_error(kind, token, procedure, classes) is not None: continue - original = lookup.get((procedure.native_name or procedure.name).casefold()) + original = lookup.get(self._declared_specific(procedure)) if original is not None: original.projection = self._assignment_projection(original, 0) @staticmethod def _mark_constructor_specifics( procedures: list[SemanticFunction], - procedure_lookup: dict[str, SemanticFunction], + procedure_lookup: dict[tuple[str, str], SemanticFunction], type_name: str, ) -> None: """Hide the module functions a generic constructor selects between. @@ -3376,7 +3384,7 @@ def _mark_constructor_specifics( the public spelling the source chose for it. """ for procedure in procedures: - original = procedure_lookup.get((procedure.native_name or procedure.name).casefold()) + original = procedure_lookup.get(FortranToIRConverter._declared_specific(procedure)) if original is not None: original.metadata[CONSTRUCTOR_SPECIFIC_METADATA] = type_name diff --git a/tests/fortran/generic_interfaces/semantics/test_generic_contributor_merging.py b/tests/fortran/generic_interfaces/semantics/test_generic_contributor_merging.py index e669ec4fd..b03a65da4 100644 --- a/tests/fortran/generic_interfaces/semantics/test_generic_contributor_merging.py +++ b/tests/fortran/generic_interfaces/semantics/test_generic_contributor_merging.py @@ -309,3 +309,39 @@ def test_a_contract_names_each_merged_specific_distinctly(tmp_path: Path): # Each dispatcher names the declaration this contract actually writes. assert '@overload("to_value")' in contract assert '@overload("to_value_2")' in contract + + +def test_a_type_bound_assignment_reaches_the_method_it_projects(tmp_path: Path): + """The generic's candidate and the method it names are one declaration. + + A defined assignment projects its bound object as the result. The original + method has to carry that projection too, so both the generic call and a + direct call behave the same way. + """ + modules = _modules( + tmp_path, + """\ +module asg_mod + implicit none + type :: box_t + integer :: value = 0 + contains + procedure :: assign_value + generic :: assignment(=) => assign_value + end type box_t +contains + subroutine assign_value(self, other) + class(box_t), intent(inout) :: self + integer, intent(in) :: other + self%value = other + end subroutine assign_value +end module asg_mod +""", + ) + declared = modules["asg_mod"].classes[0] + method = next(item for item in declared.methods if item.name == "assign_value") + + assert [(item.python_name, item.result_position) for item in method.projection] == [ + ("self", 0), + ("other", None), + ] From 334ecca15126adfeab07a80944a68eaa4aeada65 Mon Sep 17 00:00:00 2001 From: said Date: Fri, 18 Sep 2026 09:57:35 +0100 Subject: [PATCH 72/96] Hold a fix to removing an interpretation path Passing tests say a bug is gone; they do not say the code got simpler. This branch has reached the size where local patches start working against the architecture, so record the criterion we are now reviewing against: a fix either deletes a way of deciding or it does not land. The rule names the shapes that keep producing these bugs -- a record that has to mean several things, a lookup reached by two key shapes, a completed decision ambiguous with an absent one, a consumer special-casing what a plan should have decided -- and says to replace the structure rather than widen it. Introducing a record or a small class is the preferred move where it lets a reader see the rule in one place; the condition is that it deletes what it replaces, since moving or wrapping leaves every existing reader standing. It also says to keep the accumulated regressions while doing that. They are the specification of what PRIK supports, and simplifying by dropping cases is not simplifying. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- AGENTS.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index c2d1cac26..f990188d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,33 @@ when the derivation keeps a ledger: two allocators fed the same declarations in a different order produce the same set of names attached to different declarations, which every per-stage test still passes. +A fix removes an interpretation path or it does not land. "The regression is +fixed and the tests pass" is half an answer; the other half is **did this delete +a way of deciding, or add one?** A representation that has to mean several +things is the usual source of these bugs, and widening it with another flag or +another fallback leaves every existing reader intact and adds a reader. So when +a record cannot express a case, replace the record; when a lookup is reached by +two key shapes, finish the migration to one; when a completed decision is +ambiguous with an absent one, make completion record it; when a consumer +special-cases what a plan should have decided, move the decision into the plan. +Introducing a record, a small class, or a named reading is the preferred move +when it lets a reader see the rule in one place, and it does not need a separate +mandate: reach for it whenever it fixes the bug in fewer lines than another +branch would, and change an existing structure freely when replacing it is what +makes the code read more simply. Prefer that to a new condition threaded through +existing paths, which each reader then has to hold in mind. The one condition is +that the new thing is accepted only if it deletes the branches and helpers it +replaces — moving them to another module, or wrapping them behind a new name, +does not count. The practical test before committing: the file you changed +should be no harder to read than before, and the count of places that answer +your question should have gone down. + +Keep the regressions while doing it. The tests that pin bare, `only`, renamed +and repeated `use` forms, route accessibility, transitive re-exports, merged +generics, prototype collisions, exact `__all__`, and source-build versus +generated-`.pyi` replay are the specification of what PRIK supports; simplify +what sits under them, never by dropping the cases they cover. + Where one decision reaches users through two artifacts, a test must compare those artifacts rather than only check each one. A built extension and the `.pyi` contract describing it are one such pair: each had passing tests while From e35c976e0ebbf48a1a6578c34a2ed08314c9588f Mon Sep 17 00:00:00 2001 From: said Date: Fri, 18 Sep 2026 11:27:20 +0100 Subject: [PATCH 73/96] Read one scope's use statements through one resolver `uses` was still a dictionary keyed by module name, which left three problems that the statement record alone could not fix. The key repeated `statement.module`, and its values were mutable lists a procedure inherited by sharing: `proc_state.uses.update(module.uses)` copies the dictionary and not the lists, so a `use dep, only : y` written inside a contained procedure appended to the list the module itself held. A procedure-local import therefore reached module accessibility and re-export analysis. Module names are also case-insensitive, so `use DEP` and `use dep` were held under separate keys although the language reads them as one use. And `FortranUseAssociation.carried()` returned one entity per local name. Fortran does not promise that: `use dep, x => y` where `dep` also publishes `x` makes two entities reach `x`, and the second silently won. So `uses` becomes `list[FortranUseStatement]`, frozen with tuple mappings, appended rather than merged; a scope inherits by concatenation. `ScopeUses` groups them case-insensitively and answers the two questions consumers have -- which routes reach a local name, and which names a scope reaches. It reports routes rather than choosing between them, because whether two routes are an ambiguity or a set of contributors is a question about the entities, which the stage holding them already decides. Deleted rather than moved: `_record_use_mappings()`, `FortranUseAssociation` and its `carried()`, the parent-diffing branch of `_procedure_local_uses()`, `_fortran_owner_used_modules()` in the build, and the remaining per-consumer `imports_all` / `mappings` walks in route collection, public names, callback interfaces, contract imports, declaration calls and derived-type origins. `used_module_names()` is now the one reading of what a scope depends on, shared by project indexing, file ordering and compile batching -- which had been three readings, and which the aliasing bug had been quietly propping up. Existing files lose 105 lines; the resolver adds 130 in one place. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 9 + prik/parsers/fortran/models.py | 75 +------- prik/parsers/fortran/parser.py | 74 ++++---- prik/parsers/fortran/scope.py | 130 ++++++++++++++ prik/pipeline/build.py | 22 +-- prik/semantics/fortran2ir.py | 170 ++++++++---------- .../parsing/test_declarations_and_shapes.py | 4 +- .../semantics/test_types_and_storage.py | 10 +- .../test_imported_derived_semantics.py | 12 +- ...est_procedure_and_interface_regressions.py | 5 +- .../assumed_shape_and_derived_args.json | 12 +- .../fixtures/general/basic_subroutine.json | 8 +- .../general/compile_time_all_exprs.json | 8 +- .../general/compile_time_shape_exprs.json | 8 +- .../fixtures/general/derived_type.json | 8 +- .../general/derived_types_and_methods.json | 4 +- .../fixtures/general/f77_subroutine.json | 4 +- .../fixtures/general/modern_pyi_example.json | 32 ++-- .../fixtures/general/module_vars_use.json | 68 ++++--- .../general/procedures_and_functions.json | 12 +- .../scope_name_reuse_combinations.json | 36 ++-- .../test_declaration_and_interface_edges.py | 23 +-- .../parsing/test_fortran_fixture_suite.py | 7 +- ...ortran_parser_procedures_and_interfaces.py | 8 +- .../parsing/test_project_scope_models.py | 10 +- .../modules/parsing/test_scope_handling.py | 79 +++++++- .../semantics/test_modules_and_imports.py | 7 +- 27 files changed, 469 insertions(+), 376 deletions(-) create mode 100644 prik/parsers/fortran/scope.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 629fadb2c..2d18ea9d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ release tags add a leading `v` to the package version. ## Unreleased +- A scope's `use` statements are kept as a flat, immutable list and read by one + resolver. A procedure inheriting its module's imports could previously append + to the very list the module held, so `use dep, only : y` written inside a + contained procedure reached module accessibility and re-export analysis. Two + statements spelling one module differently (`use DEP` beside `use dep`) were + also held apart, and a local name reached by two entities -- `use dep, x => y` + where `dep` also publishes `x` -- silently resolved to one of them instead of + being reported ambiguous. + - A type-bound defined assignment updates the method it names. Making a specific's identity structural left two helpers looking the original up by bare name, so `generic :: assignment(=) => assign_value` projected its bound diff --git a/prik/parsers/fortran/models.py b/prik/parsers/fortran/models.py index ee49ceab3..4608846b1 100644 --- a/prik/parsers/fortran/models.py +++ b/prik/parsers/fortran/models.py @@ -15,7 +15,6 @@ import re import sys from dataclasses import dataclass, field -from collections.abc import Iterable, Mapping from typing import Any from prik.parsers.fortran.type_resolver import extract_character_selector @@ -369,7 +368,7 @@ class FortranProcedureSignature: result: FortranArgument | None = None attributes: list[str] = field(default_factory=list) bind_name: str | None = None - uses: dict[str, list[FortranUseStatement]] = field(default_factory=dict) + uses: list[FortranUseStatement] = field(default_factory=list) in_interface: bool = False variables: dict[str, FortranVariable] = field(default_factory=dict) common_variables: list[str] = field(default_factory=list) @@ -427,86 +426,28 @@ class FortranEnum: visibility: str = "public" -@dataclass +@dataclass(frozen=True) class FortranUseStatement: """One ``use`` statement exactly as the source writes it. ``only`` records whether the statement narrowed to an ``only`` list, which is independent of what it listed: ``use m`` lists nothing and narrows nothing, ``use m, only :`` lists nothing and narrows to nothing. Statements - are kept apart because the language reads several for one module together, - and combining them while parsing would lose what each one said. + are kept apart and immutable because the language reads several for one + module together, and a scope that inherits another's imports must not be + able to add to them. """ module: str only: bool = False - mappings: list[FortranUseMapping] = field(default_factory=list) - - -@dataclass(frozen=True) -class FortranUseAssociation: - """What one scope's ``use`` statements of a single module make visible. - - This is the one reading of those statements. Every consumer asks it what a - scope sees, rather than deciding for itself what an empty mapping list or a - rename means. - """ - - imports_all: bool = False mappings: tuple[FortranUseMapping, ...] = () - @classmethod - def of(cls, statements: Iterable[FortranUseStatement]) -> FortranUseAssociation: - """Combine every ``use`` statement naming one module into one reading. - - Each statement adds the names it lists, and any statement without - ``only`` makes the module's remaining public names accessible too. - """ - mappings: dict[tuple[str, str], FortranUseMapping] = {} - imports_all = False - for statement in statements: - imports_all = imports_all or not statement.only - for mapping in statement.mappings: - mappings.setdefault((mapping.source.casefold(), mapping.local_name.casefold()), mapping) - return cls(imports_all, tuple(mappings.values())) - - @property - def renamed_sources(self) -> frozenset[str]: - """Return the names a rename reaches, which are not reachable as written. - - ``use m, p => q`` accesses that entity as ``p``; ``q`` names nothing in - the importing scope. - """ - return frozenset(item.source.casefold() for item in self.mappings if item.target) - - def carried(self, offered: Mapping[str, Any]) -> dict[str, Any]: - """Return what this association brings in, keyed by its local name. - - ``offered`` maps each name the used module publishes to whatever the - caller tracks for it. A listed name arrives under the spelling it binds; - the rest arrive unchanged when the association imports all. - """ - carried: dict[str, Any] = {} - if self.imports_all: - renamed = self.renamed_sources - carried.update((name, value) for name, value in offered.items() if name not in renamed) - for mapping in self.mappings: - source = mapping.source.casefold() - if source in offered: - carried[mapping.local_name.casefold()] = offered[source] - return carried - - -def use_associations(uses: Mapping[str, Iterable[FortranUseStatement]]) -> dict[str, FortranUseAssociation]: - """Read one scope's whole import table as an association per used module.""" - return {module: FortranUseAssociation.of(statements) for module, statements in uses.items()} - @dataclass class FortranModule: name: str filename: str | None = None - uses: dict[str, list[FortranUseStatement]] = field(default_factory=dict) + uses: list[FortranUseStatement] = field(default_factory=list) variables: list[FortranVariable] = field(default_factory=list) procedures: list[FortranProcedureSignature] = field(default_factory=list) derived_types: list[FortranDerivedType] = field(default_factory=list) @@ -524,7 +465,7 @@ class FortranSubmodule: parent: str ancestor: str | None = None filename: str | None = None - uses: dict[str, list[FortranUseStatement]] = field(default_factory=dict) + uses: list[FortranUseStatement] = field(default_factory=list) variables: list[FortranVariable] = field(default_factory=list) procedures: list[FortranProcedureSignature] = field(default_factory=list) derived_types: list[FortranDerivedType] = field(default_factory=list) @@ -537,7 +478,7 @@ class FortranSubmodule: class FortranProgram: name: str | None = None filename: str | None = None - uses: dict[str, list[FortranUseStatement]] = field(default_factory=dict) + uses: list[FortranUseStatement] = field(default_factory=list) variables: list[FortranVariable] = field(default_factory=list) procedures: list[FortranProcedureSignature] = field(default_factory=list) enums: list[FortranEnum] = field(default_factory=list) diff --git a/prik/parsers/fortran/parser.py b/prik/parsers/fortran/parser.py index 3f624e3d3..66d4bcdfe 100644 --- a/prik/parsers/fortran/parser.py +++ b/prik/parsers/fortran/parser.py @@ -10,7 +10,7 @@ from __future__ import annotations import re -from collections.abc import Mapping, Sequence +from collections.abc import Iterable, Mapping, Sequence from copy import deepcopy from dataclasses import dataclass, field as dataclass_field, replace from pathlib import Path @@ -28,6 +28,7 @@ from prik.utilities.visitor import ClassVisitor from prik.parsers.fortran.lexer import preprocess_lines +from prik.parsers.fortran.scope import ScopeUses, used_module_names from prik.parsers.fortran.models import ( FortranArgument, FortranBlockData, @@ -43,7 +44,6 @@ FortranProject, FortranSubmodule, FortranUseStatement, - use_associations, FortranUseMapping, FortranVariable, ) @@ -395,8 +395,8 @@ class _ProcedureState: signature: FortranProcedureSignature symbols: dict[str, FortranArgument] typed_symbols: set[str] = dataclass_field(default_factory=set) - uses: dict[str, list[FortranUseMapping]] = dataclass_field(default_factory=dict) - local_uses: dict[str, list[FortranUseMapping]] = dataclass_field(default_factory=dict) + uses: list[FortranUseStatement] = dataclass_field(default_factory=list) + local_uses: list[FortranUseStatement] = dataclass_field(default_factory=list) local_params: dict[str, str] = dataclass_field(default_factory=dict) legacy_local_params: set[str] = dataclass_field(default_factory=set) implicit_typed_symbols: dict[str, str] = dataclass_field(default_factory=dict) @@ -1873,7 +1873,7 @@ def _visit_ProcedureUnit( proc_state.filename = filename proc_state.header_lineno = header[1] proc_state.header_source_line = header[2] - proc_state.uses.update(getattr(parent_scope.model, "uses", {})) + proc_state.uses.extend(getattr(parent_scope.model, "uses", ())) scope = self._helper_scope_for_model("procedure", proc_state.signature, parent=parent_scope, state=proc_state) self._parse_specification_part(scope, unit.specification, filename=filename) child_units = unit.children @@ -2154,9 +2154,9 @@ def _project_file_requirements(parsed_file: FortranFile) -> set[str]: """ requirements: set[str] = set() for module in parsed_file.modules: - requirements.update(name.lower() for name in module.uses) + requirements.update(used_module_names(module)) for submodule in parsed_file.submodules: - requirements.update(name.lower() for name in submodule.uses) + requirements.update(used_module_names(submodule)) requirements.add(submodule.parent.lower()) if submodule.ancestor: requirements.add(submodule.ancestor.lower()) @@ -2305,7 +2305,7 @@ def _helper_index_project_module(self, project: FortranProject, module: FortranM """Index one module and its owned public models.""" module_key = module.name.lower() self._insert_unique_scope_symbol(project.modules, module_key, module, label="project module scope") - project.dependencies[module_key] = {name.lower() for name in module.uses} + project.dependencies[module_key] = used_module_names(module) self._helper_index_project_owner_members(project, module, module_key) def _helper_index_project_submodule(self, project: FortranProject, submodule: FortranSubmodule) -> None: @@ -2317,7 +2317,7 @@ def _helper_index_project_submodule(self, project: FortranProject, submodule: Fo submodule, label="project submodule scope", ) - dependencies = {submodule.parent.lower(), *(name.lower() for name in submodule.uses)} + dependencies = {submodule.parent.lower(), *used_module_names(submodule)} if submodule.ancestor: dependencies.add(submodule.ancestor.lower()) project.dependencies[submodule_key] = dependencies @@ -2375,7 +2375,7 @@ def _helper_index_project_program(self, project: FortranProject, program: Fortra return program_key = program.name.lower() self._insert_unique_scope_symbol(project.programs, program_key, program, label="project program scope") - project.dependencies[program_key] = {name.lower() for name in program.uses} + project.dependencies[program_key] = used_module_names(program) def _helper_index_project_interface( self, @@ -3495,7 +3495,7 @@ def _parse_module_like_spec_line( parsed_use = self._parse_use_statement(stripped) if parsed_use and hasattr(target, "uses"): - self._record_use_mappings(target.uses, parsed_use) + target.uses.append(parsed_use) return if _REGEX["derived_type"].match(stripped): @@ -3662,8 +3662,8 @@ def _parse_procedure_spec_line( return parsed_use = self._parse_use_statement(stripped) if parsed_use: - self._record_use_mappings(proc_state.uses, parsed_use) - self._record_use_mappings(proc_state.local_uses, parsed_use) + proc_state.uses.append(parsed_use) + proc_state.local_uses.append(parsed_use) return # This parser is a subset parser focused on wrapper-relevant metadata. # These statements do not affect extracted signature typing/shapes. @@ -4931,7 +4931,7 @@ def _attach_procedure_scope_metadata( attr = f"import({symbol})" if attr not in sig.attributes: sig.attributes.append(attr) - sig.uses = dict(state.uses) + sig.uses = list(state.uses) sig.common_variables = list(state.common_variables) @staticmethod @@ -4948,7 +4948,7 @@ def _copy_finalized_procedure_signature( not deep-copy arguments or other signature members. """ finalized = replace(sig) - finalized._local_uses = dict(state.local_uses) + finalized._local_uses = list(state.local_uses) return finalized @staticmethod @@ -5231,7 +5231,7 @@ def _build_compile_time_symbols( @staticmethod def _imported_compile_time_symbols( - uses: Mapping[str, list[FortranUseStatement]], + uses: Iterable[FortranUseStatement], symbols: _CompileTimeSymbols, *, include_intrinsic_aliases: bool, @@ -5246,16 +5246,27 @@ def _imported_compile_time_symbols( when the intrinsic module has no parsed model; ordinary procedure scope lookup leaves that target-dependent spelling untouched. """ + scope = ScopeUses(uses) + offered = {module: symbols.in_module(module.casefold()) for module in scope.modules()} imported: dict[str, str] = {} - for dependency, association in use_associations(uses).items(): - dependency_name = dependency.casefold() - dependency_symbols = symbols.in_module(dependency_name) - imported.update(association.carried(dependency_symbols)) - if not include_intrinsic_aliases or dependency_name not in _INTRINSIC_COMPILE_TIME_MODULES: + for name in scope.accessible_names(lambda module: offered[module]): + expressions = { + offered[route.module][route.source_name.casefold()] + for route in scope.routes_for(name, lambda module: offered[module]) + if route.source_name.casefold() in offered[route.module] + } + # Routes that disagree leave the name meaning more than one value, + # which is not something to choose between. + if len(expressions) == 1: + imported[name.casefold()] = next(iter(expressions)) + if not include_intrinsic_aliases: + return imported + # An intrinsic module has no parsed symbols, so a name imported from + # one stands for its own target-dependent spelling. + for module in scope.modules(): + if module.casefold() not in _INTRINSIC_COMPILE_TIME_MODULES: continue - # An intrinsic module has no parsed symbols, so a name imported - # from one stands for its own target-dependent spelling. - for mapping in association.mappings: + for mapping in scope.mappings(module): imported.setdefault(mapping.local_name.casefold(), mapping.source) return imported @@ -5814,19 +5825,6 @@ 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[FortranUseStatement]], - statement: FortranUseStatement, - ) -> None: - """Append one ``use`` statement to a scope's import table. - - A scope may name the same module more than once, and what each - statement said is a source fact, so they are kept apart here and read - together by ``FortranUseAssociation``. - """ - uses.setdefault(statement.module, []).append(statement) - @staticmethod def _parse_use_statement(line: str) -> FortranUseStatement | None: """Parse one ``use`` statement into the facts the source states. @@ -5856,7 +5854,7 @@ def _parse_use_statement(line: str) -> FortranUseStatement | None: source = token target = None mappings.append(FortranUseMapping(source=source, target=target)) - return FortranUseStatement(match.group("module"), only_match is not None, mappings) + return FortranUseStatement(match.group("module"), only_match is not None, tuple(mappings)) # ----------------------------------------------------------------------------- diff --git a/prik/parsers/fortran/scope.py b/prik/parsers/fortran/scope.py new file mode 100644 index 000000000..3c6b79dc4 --- /dev/null +++ b/prik/parsers/fortran/scope.py @@ -0,0 +1,130 @@ +"""How one Fortran scope reads its ``use`` statements. + +A scope may name one module in several statements, spelled any way, and the +language reads them together: an ``only`` list narrows what its own statement +brings in, a rename binds an entity under a new name and leaves the old one +naming nothing, and any statement without ``only`` carries whatever else the +module publishes. + +This is the single reading of that. It answers what a scope sees under a local +name and by which routes, and it answers nothing else: several routes to +different entities are reported as several routes, because whether that is an +ambiguity or a set of contributors is a question about the entities, which the +stage holding them decides. +""" + +from __future__ import annotations + +from collections.abc import Callable, Collection, Iterable +from dataclasses import dataclass + +from prik.parsers.fortran.models import FortranUseMapping, FortranUseStatement + +#: Answers the public names one used module offers, or ``None`` when unread. +OfferedNames = Callable[[str], Collection[str] | None] + + +@dataclass(frozen=True) +class UseRoute: + """One way a scope reaches a name: the module used, and the name there.""" + + module: str + source_name: str + + @property + def key(self) -> tuple[str, str]: + """Return the case-folded identity two spellings of one route share.""" + return self.module.casefold(), self.source_name.casefold() + + +class ScopeUses: + """One scope's ``use`` statements, grouped by the module each names. + + Fortran module names are case-insensitive, so ``use DEP`` and ``use dep`` + are statements about one module and are read together. + """ + + def __init__(self, statements: Iterable[FortranUseStatement]) -> None: + self._by_module: dict[str, list[FortranUseStatement]] = {} + for statement in statements: + self._by_module.setdefault(statement.module.casefold(), []).append(statement) + + def modules(self) -> tuple[str, ...]: + """Return each used module once, spelled as its first statement wrote it.""" + return tuple(statements[0].module for statements in self._by_module.values()) + + def imports_all(self, module: str) -> bool: + """Return whether any statement for ``module`` omitted ``only``.""" + return any(not statement.only for statement in self._by_module.get(module.casefold(), ())) + + def mappings(self, module: str) -> tuple[FortranUseMapping, ...]: + """Return every name the statements for ``module`` listed, in order.""" + seen: dict[tuple[str, str], FortranUseMapping] = {} + for statement in self._by_module.get(module.casefold(), ()): + for mapping in statement.mappings: + seen.setdefault((mapping.source.casefold(), mapping.local_name.casefold()), mapping) + return tuple(seen.values()) + + def routes_for(self, local_name: str, offered: OfferedNames) -> tuple[UseRoute, ...]: + """Return every route by which this scope reaches one local name. + + A listed name states its own route. A module imported whole is a route + for a name it publishes, unless a rename took that name away. A module + this project never read cannot be enumerated, so it offers no route + rather than an assumed one. + """ + folded = local_name.casefold() + routes: dict[tuple[str, str], UseRoute] = {} + for module in self.modules(): + for mapping in self.mappings(module): + if mapping.local_name.casefold() == folded: + route = UseRoute(module, mapping.source) + routes.setdefault(route.key, route) + for module in self.modules(): + if not self.imports_all(module) or folded in self._renamed_away(module): + continue + names = offered(module) + if names is not None and folded in names: + route = UseRoute(module, local_name) + routes.setdefault(route.key, route) + return tuple(routes.values()) + + def accessible_names(self, offered: OfferedNames) -> tuple[str, ...]: + """Return every local name this scope reaches, in source order. + + A listed name keeps the spelling its ``use`` statement bound it under; + a name carried whole keeps the spelling its module publishes. + """ + names: dict[str, str] = {} + for module in self.modules(): + for mapping in self.mappings(module): + names.setdefault(mapping.local_name.casefold(), mapping.local_name) + for module in self.modules(): + if not self.imports_all(module): + continue + renamed_away = self._renamed_away(module) + for name in sorted(offered(module) or ()): + if name not in renamed_away: + names.setdefault(name.casefold(), name) + return tuple(names.values()) + + def _renamed_away(self, module: str) -> frozenset[str]: + """Return the names a rename reaches, which are not reachable as written.""" + return frozenset(item.source.casefold() for item in self.mappings(module) if item.target) + + +def used_module_names(owner: object) -> set[str]: + """Return every module one scope names, lowercased. + + A ``use`` written inside a contained procedure or an interface body is a + dependency of the scope holding it just as much as one written at its top, + so the whole tree is read. Compile ordering and project dependencies both + ask this, and they have to get the same answer. + """ + statements: list[FortranUseStatement] = list(getattr(owner, "uses", ())) + for procedure in getattr(owner, "procedures", ()): + statements.extend(getattr(procedure, "uses", ())) + for interface in getattr(owner, "interfaces", ()): + for procedure in getattr(interface, "procedures", ()): + statements.extend(getattr(procedure, "uses", ())) + return {statement.module.lower() for statement in statements} diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index 5e22d85c9..ebf1b03b3 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -39,6 +39,7 @@ from prik.parsers.c import parse_c_file from prik.parsers.c.cli import attach_preprocessing_recipe from prik.parsers.fortran.parser import parse_fortran_project +from prik.parsers.fortran.scope import used_module_names from prik.preprocessing.probes.fortran_types import ( evaluate_fortran_type_facts, evaluate_fortran_type_requirements, @@ -1640,22 +1641,6 @@ def _serial_compile_batches(object_files: Iterable[ObjectFile]) -> tuple[tuple[O return tuple((object_file,) for object_file in object_files) -def _fortran_owner_used_modules(owner: object) -> set[str]: - """Return lowercased modules used directly or indirectly by one owner. - - ``owner`` may be a parsed module, program, procedure, or submodule. The - helper reads its ``uses`` mappings and the uses of contained procedures and - interface procedures, returning a new set without changing the parsed AST. - """ - used = {str(name).lower() for name in getattr(owner, "uses", {})} - for procedure in getattr(owner, "procedures", ()): - used.update(str(name).lower() for name in getattr(procedure, "uses", {})) - for interface in getattr(owner, "interfaces", ()): - for procedure in getattr(interface, "procedures", ()): - used.update(str(name).lower() for name in getattr(procedure, "uses", {})) - return used - - def _fortran_file_used_modules(parsed_file: object) -> set[str]: """Return lowercased module dependencies declared by one parsed file. @@ -1671,10 +1656,9 @@ def _fortran_file_used_modules(parsed_file: object) -> set[str]: ) used = set() for owner in owners: - used.update(_fortran_owner_used_modules(owner)) + used.update(used_module_names(owner)) for interface in getattr(parsed_file, "interfaces", ()): - for procedure in getattr(interface, "procedures", ()): - used.update(str(name).lower() for name in getattr(procedure, "uses", {})) + used.update(used_module_names(interface)) for submodule in getattr(parsed_file, "submodules", ()): used.add(str(submodule.parent).lower()) if submodule.ancestor: diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 5db216f9b..2f1dcf2e1 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -22,6 +22,7 @@ import re from pathlib import Path +from prik.parsers.fortran.scope import ScopeUses, UseRoute from prik.parsers.fortran.models import ( FortranArgument, FortranBlockData, @@ -36,7 +37,6 @@ FortranProcedureSignature, FortranSubmodule, FortranUseStatement, - use_associations, FortranVariable, ) from prik.utilities.declaration_expressions import ( @@ -227,13 +227,6 @@ def key(self) -> tuple[str, str]: return self.module.casefold(), self.name.casefold() -class _NameRoute(NamedTuple): - """One way a module reaches a name: the module used, and the name there.""" - - used_module: str - source_name: str - - @dataclass(frozen=True) class _DerivedTypeContext: """Keep lexical derived-type lookup facts while one parser node is converted. @@ -244,8 +237,8 @@ class _DerivedTypeContext: """ module: str | None = None - uses: dict[str, list[FortranUseStatement]] | None = None - procedure_uses: dict[str, list[FortranUseStatement]] | None = None + uses: list[FortranUseStatement] | None = None + procedure_uses: list[FortranUseStatement] | None = None local_types: frozenset[str] = frozenset() @@ -269,7 +262,7 @@ class _DeclarationCallableContext: module: str | None local_procedures: dict[str, SemanticFunction] local_interfaces: dict[str, SemanticPrototype] - uses: dict[str, list[FortranUseStatement]] + uses: list[FortranUseStatement] def _normalize_compile_time_values( @@ -821,7 +814,7 @@ def _module_callback_interfaces( def _scope_callback_interfaces( cls, modules: dict[str, FortranModule], - uses: dict[str, list[FortranUseStatement]], + uses: list[FortranUseStatement], *, base: dict[str, _CallbackInterface], owner: FortranModule | None = None, @@ -878,7 +871,7 @@ def _merge_imported_callback_interfaces( cls, visible: dict[str, _CallbackInterface], modules: dict[str, FortranModule], - uses: dict[str, list[FortranUseStatement]], + uses: list[FortranUseStatement], *, seen: frozenset[str], override: bool, @@ -895,10 +888,11 @@ def _merge_imported_callback_interfaces( """ declared_here = set(visible) candidates: dict[str, set[tuple[str | None, str] | None]] = {} - for module_name, association in use_associations(uses).items(): + scope = ScopeUses(uses) + for module_name in scope.modules(): source_module = modules.get(module_name.casefold()) if source_module is None: - for mapping in association.mappings: + for mapping in scope.mappings(module_name): candidates.setdefault(mapping.local_name.casefold(), set()).add(None) continue source_lookup = cls._module_callback_interfaces( @@ -907,13 +901,16 @@ def _merge_imported_callback_interfaces( seen=seen, exported_only=True, ) - imported = association.carried(source_lookup) - # A rename binds the interface under the name the importing scope - # gives it, which a contract written here has to state. - for mapping in association.mappings: - local = mapping.local_name.casefold() - if local in imported: - imported[local] = replace(imported[local], local_name=mapping.local_name) + imported: dict[str, _CallbackInterface] = {} + for mapping in scope.mappings(module_name): + resolved = source_lookup.get(mapping.source.casefold()) + if resolved is not None: + # A rename binds the interface under the name the importing + # scope gives it, which a contract here has to state. + imported[mapping.local_name.casefold()] = replace(resolved, local_name=mapping.local_name) + if scope.imports_all(module_name): + for name, resolved in source_lookup.items(): + imported.setdefault(name, resolved) for name, resolved in imported.items(): candidates.setdefault(name, set()).add(cls._callback_identity(resolved)) if override: @@ -1612,7 +1609,7 @@ def _visit_FortranModule( module, functions=semantic_functions, prototypes=prototypes, - uses={**module.uses, **procedure.uses}, + uses=[*module.uses, *procedure.uses], ) self._record_function_declaration_callables(function, callable_context) @@ -1772,7 +1769,7 @@ def procedures_to_semantic_module( module=None, local_procedures=function_lookup, local_interfaces={}, - uses=dict(procedure.uses), + uses=list(procedure.uses), ), ) return SemanticModule( @@ -1953,15 +1950,11 @@ def _module_public_names( seen = seen | {key} is_public = cls._effective_accessibility(module) offered: dict[str, set[str]] = {name: set() for name in cls._module_declared_names(module)} - for module_name, association in use_associations(module.uses).items(): - for mapping in association.mappings: - offered.setdefault(mapping.local_name.casefold(), set()).add(module_name) - used = index.get(module_name.casefold()) - if used is None: - continue - reachable = dict.fromkeys(cls._module_public_names(used, index, seen), True) - for name in association.carried(reachable): - offered.setdefault(name, set()).add(module_name) + scope = ScopeUses(module.uses) + reachable = cls._offered_names(index, seen) + for name in scope.accessible_names(reachable): + folded = name.casefold() + offered.setdefault(folded, set()).update(route.module for route in scope.routes_for(name, reachable)) return {name for name, routes in offered.items() if is_public(name, routes)} @staticmethod @@ -1987,13 +1980,23 @@ def _reconcile_routes(origins: list[tuple[str, str, str]]) -> tuple[str, str, st return distinct[0] return None + @classmethod + def _offered_names(cls, index: dict[str, FortranModule], seen: frozenset[str] = frozenset()): + """Return what each used module publishes, or ``None`` when unparsed.""" + + def offered(module_name: str): + used = index.get(module_name.casefold()) + return None if used is None else cls._module_public_names(used, index, seen) + + return offered + @classmethod def _name_routes( cls, module: FortranModule, index: dict[str, FortranModule], local_name: str, - ) -> tuple[_NameRoute, ...]: + ) -> tuple[UseRoute, ...]: """Return every route by which one module reaches one local name. A named mapping states the name it carries. A plain ``use`` carries @@ -2004,20 +2007,7 @@ def _name_routes( ``use``, says nothing about what it carries, so both kinds are collected together and weighed the same way afterwards. """ - folded = local_name.casefold() - routes: list[_NameRoute] = [ - _NameRoute(used_name, mapping.source) - for used_name, association in use_associations(module.uses).items() - for mapping in association.mappings - if mapping.local_name.casefold() == folded - ] - for used_name, association in use_associations(module.uses).items(): - if not association.imports_all or folded in association.renamed_sources: - continue - used = index.get(used_name.casefold()) - if used is not None and folded in cls._module_public_names(used, index): - routes.append(_NameRoute(used_name, local_name)) - return tuple(routes) + return ScopeUses(module.uses).routes_for(local_name, cls._offered_names(index)) @classmethod def _use_associated_names( @@ -2032,19 +2022,7 @@ def _use_associated_names( for a parsed one. Named spellings come first, so a name reached both ways keeps the case its ``use`` statement wrote. """ - names: dict[str, str] = {} - associations = use_associations(module.uses) - for association in associations.values(): - for mapping in association.mappings: - names.setdefault(mapping.local_name.casefold(), mapping.local_name) - for used_name, association in associations.items(): - used = index.get(used_name.casefold()) - if used is None: - continue - offered = dict.fromkeys(sorted(cls._module_public_names(used, index)), True) - for name in association.carried(offered): - names.setdefault(name, name) - return tuple(names.values()) + return ScopeUses(module.uses).accessible_names(cls._offered_names(index)) def _module_reexports( cls, @@ -2068,11 +2046,11 @@ def _module_reexports( for local_name in cls._use_associated_names(module, index): local_key = local_name.casefold() routes = cls._name_routes(module, index, local_name) - route_names = tuple(dict.fromkeys(route.used_module for route in routes)) + route_names = tuple(dict.fromkeys(route.module for route in routes)) if local_key in declared or not routes or not is_public(local_name, route_names): continue origin = cls._reconcile_routes( - [cls._resolve_reexport_origin(index, route.used_module, route.source_name) for route in routes] + [cls._resolve_reexport_origin(index, route.module, route.source_name) for route in routes] ) if origin is None: continue @@ -2124,11 +2102,11 @@ def _resolve_reexport_origin( return kind, declaring.name, source_name seen = seen | {key} routes = cls._name_routes(declaring, index, source_name) - route_names = tuple(dict.fromkeys(route.used_module for route in routes)) + route_names = tuple(dict.fromkeys(route.module for route in routes)) if not routes or not cls._effective_accessibility(declaring)(source_name, route_names): return "unknown", module_name, source_name origin = cls._reconcile_routes( - [cls._resolve_reexport_origin(index, route.used_module, route.source_name, seen) for route in routes] + [cls._resolve_reexport_origin(index, route.module, route.source_name, seen) for route in routes] ) return origin if origin is not None else ("unknown", module_name, source_name) @@ -2166,16 +2144,16 @@ def _module_imports(module: FortranModule) -> list[str | SemanticImport]: means on its own. """ imports: list[str | SemanticImport] = [] - for module_name, association in use_associations(module.uses).items(): - if association.imports_all: + scope = ScopeUses(module.uses) + for module_name in scope.modules(): + if scope.imports_all(module_name): imports.append(module_name) - if association.mappings: + mappings = scope.mappings(module_name) + if mappings: imports.append( SemanticImport( module=module_name, - items=[ - SemanticImportItem(source=item.source, target=item.target) for item in association.mappings - ], + items=[SemanticImportItem(source=item.source, target=item.target) for item in mappings], ) ) return imports @@ -2186,14 +2164,14 @@ def _declaration_callable_context( functions: Iterable[SemanticFunction] = (), prototypes: Iterable[SemanticPrototype] = (), *, - uses: dict[str, list[FortranUseStatement]] | None = None, + uses: list[FortranUseStatement] | None = None, ) -> _DeclarationCallableContext: """Build lexical procedure facts for one module-owned declaration.""" return _DeclarationCallableContext( module=module.name, local_procedures={function.name.casefold(): function for function in functions}, local_interfaces={prototype.name.casefold(): prototype for prototype in prototypes}, - uses=dict(module.uses if uses is None else uses), + uses=list(module.uses if uses is None else uses), ) def _record_declaration_callables( @@ -2269,10 +2247,11 @@ def _resolve_declaration_callable( declaration=local, ) + scope = ScopeUses(context.uses) explicit = [ (module_name, mapping.source) - for module_name, association in use_associations(context.uses).items() - for mapping in association.mappings + for module_name in scope.modules() + for mapping in scope.mappings(module_name) if mapping.local_name.casefold() == key ] if len(explicit) == 1: @@ -2286,9 +2265,7 @@ def _resolve_declaration_callable( if explicit: return None - wildcard_modules = [ - name for name, association in use_associations(context.uses).items() if association.imports_all - ] + wildcard_modules = [name for name in scope.modules() if scope.imports_all(name)] known_origins = [ module_name for module_name in wildcard_modules if (module_name.casefold(), key) in self._known_procedures ] @@ -2458,31 +2435,25 @@ def _procedure_derived_type_context( The new context keeps enclosing local types while separating procedure- local imports, which later controls imported type qualification. """ - uses = dict(parent.uses or {}) if parent is not None else {} - uses.update(proc.uses) + inherited = (parent.uses or ()) if parent is not None else () + uses = [*inherited, *proc.uses] return _DerivedTypeContext( module=proc.module or (parent.module if parent is not None else None), uses=uses, - procedure_uses=FortranToIRConverter._procedure_local_uses(proc, parent), + procedure_uses=FortranToIRConverter._procedure_local_uses(proc), local_types=parent.local_types if parent is not None else frozenset(), ) @staticmethod - def _procedure_local_uses( - proc: FortranProcedureSignature, - parent: _DerivedTypeContext | None, - ) -> dict[str, list[FortranUseStatement]]: - """Return imports introduced locally by ``proc`` relative to its parent. + def _procedure_local_uses(proc: FortranProcedureSignature) -> list[FortranUseStatement]: + """Return the ``use`` statements ``proc`` writes itself. - A parser-preserved ``_local_uses`` mapping takes precedence; otherwise - only imports differing from the parent context are returned. + The parser records them apart from the ones it inherits, so a + procedure's own imports are read rather than recovered by comparing its + table with its parent's. """ local_uses = getattr(proc, "_local_uses", None) - if isinstance(local_uses, dict): - return dict(local_uses) - if parent is None or parent.uses is None: - return dict(proc.uses) - return {module: mappings for module, mappings in proc.uses.items() if parent.uses.get(module) != mappings} + return list(proc.uses if local_uses is None else local_uses) def _derived_type_ref( self, @@ -2548,7 +2519,7 @@ def _resolve_derived_type_origin( def _resolve_derived_type_origin_from_uses( self, local_name: str, - uses: dict[str, list[FortranUseStatement]] | None, + uses: list[FortranUseStatement] | None, ) -> _ResolvedDerivedTypeOrigin: """Resolve one derived-type spelling from explicit or wildcard ``use`` maps. @@ -2559,10 +2530,11 @@ def _resolve_derived_type_origin_from_uses( lname = local_name.lower() explicit: list[tuple[str, str]] = [] wildcard_modules: list[str] = [] - for module_name, association in use_associations(uses or {}).items(): - if association.imports_all: + scope = ScopeUses(uses or ()) + for module_name in scope.modules(): + if scope.imports_all(module_name): wildcard_modules.append(module_name) - for mapping in association.mappings: + for mapping in scope.mappings(module_name): if mapping.local_name.lower() == lname: explicit.append((module_name, mapping.source)) @@ -3787,14 +3759,14 @@ def _imported_generic_interfaces( """ contributors: list[tuple[FortranModule, FortranInterface]] = [] for route in cls._name_routes(module, modules, generic_name): - source_module = modules.get(route.used_module.casefold()) + source_module = modules.get(route.module.casefold()) if source_module is None: continue key = (source_module.name.casefold(), route.source_name.casefold()) if key in seen: continue onward = cls._name_routes(source_module, modules, route.source_name) - route_names = tuple(dict.fromkeys(item.used_module for item in onward)) + route_names = tuple(dict.fromkeys(item.module for item in onward)) if not cls._effective_accessibility(source_module)(route.source_name, route_names): continue declared = cls._module_generic_interface(source_module, route.source_name) diff --git a/tests/fortran/data_types/parsing/test_declarations_and_shapes.py b/tests/fortran/data_types/parsing/test_declarations_and_shapes.py index b230eb8f7..bb76dae3d 100644 --- a/tests/fortran/data_types/parsing/test_declarations_and_shapes.py +++ b/tests/fortran/data_types/parsing/test_declarations_and_shapes.py @@ -2,7 +2,7 @@ import pytest from prik.parsers.fortran import parse_fortran_file, parse_fortran_project -from prik.parsers.fortran.models import FortranUseAssociation +from prik.parsers.fortran.scope import ScopeUses from tests.fortran._support.parser_procedures import ( COMPILE_TIME_EXPRESSION_SOURCE, collect_project_procedure_signatures, @@ -137,7 +137,7 @@ def test_module_variables_and_use_statements(): assert len(modules) == 1 mod = modules[0] assert mod.name == "cfg" - assert list(FortranUseAssociation.of(mod.uses["iso_c_binding"]).mappings) == ["c_int"] + assert list(ScopeUses(mod.uses).mappings("iso_c_binding")) == ["c_int"] assert [v.name for v in mod.variables] == ["nmax", "origin"] assert mod.variables[0].is_parameter is True assert mod.variables[1].is_parameter is False 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 19e91b07e..a941d5040 100644 --- a/tests/fortran/data_types/semantics/test_types_and_storage.py +++ b/tests/fortran/data_types/semantics/test_types_and_storage.py @@ -44,12 +44,10 @@ def test_converter_visitor_and_compatibility_methods_cover_public_paths(): ) module = FortranModule( name="m", - uses={ - "iso_c_binding": [ - FortranUseStatement("iso_c_binding", True, [FortranUseMapping(source="c_int", target="i32")]) - ], - "plain_import": [FortranUseStatement("plain_import")], - }, + uses=[ + FortranUseStatement("iso_c_binding", True, (FortranUseMapping(source="c_int", target="i32"),)), + FortranUseStatement("plain_import"), + ], variables=[scale], procedures=[proc], derived_types=[dtype], diff --git a/tests/fortran/derived_types/semantics/test_imported_derived_semantics.py b/tests/fortran/derived_types/semantics/test_imported_derived_semantics.py index e040e3408..23d50d4f7 100644 --- a/tests/fortran/derived_types/semantics/test_imported_derived_semantics.py +++ b/tests/fortran/derived_types/semantics/test_imported_derived_semantics.py @@ -47,12 +47,10 @@ def test_converter_preserves_imported_derived_contexts_through_dispatch_paths(): ) module = FortranModule( name="consumer", - uses={ - "plain_mod": [FortranUseStatement("plain_mod")], - "types_mod": [ - FortranUseStatement("types_mod", True, [FortranUseMapping(source="state_t", target="local_state")]) - ], - }, + uses=[ + FortranUseStatement("plain_mod"), + FortranUseStatement("types_mod", True, (FortranUseMapping(source="state_t", target="local_state"),)), + ], variables=[FortranVariable(name="module_state", base_type="derived", kind="local_state")], procedures=[proc], derived_types=[dtype], @@ -145,7 +143,7 @@ def test_abstract_type_identity_is_module_qualified_and_available_project_wide() ) consumer = FortranModule( name="consumer", - uses={"abstract_owner": [FortranUseStatement("abstract_owner", True, [FortranUseMapping(source="item_t")])]}, + uses=[FortranUseStatement("abstract_owner", True, (FortranUseMapping(source="item_t"),))], procedures=[ FortranProcedureSignature( name="consume", diff --git a/tests/fortran/functions/parsing/test_procedure_and_interface_regressions.py b/tests/fortran/functions/parsing/test_procedure_and_interface_regressions.py index 901400bb7..1e71a7e15 100644 --- a/tests/fortran/functions/parsing/test_procedure_and_interface_regressions.py +++ b/tests/fortran/functions/parsing/test_procedure_and_interface_regressions.py @@ -1,6 +1,7 @@ """Tests split by stable ownership concept from `test_source_form_and_diagnostics_regressions.py`.""" from prik.parsers.fortran import parse_fortran_file +from prik.parsers.fortran.models import FortranUseStatement from prik.parsers.fortran.models import ( FortranArgument, FortranProcedureSignature, @@ -91,7 +92,7 @@ def test_finalize_proc_resolves_signature_arguments_imports_and_uses_without_exp signature, symbols={argument.name.lower(): argument for argument in signature.arguments}, ) - state.uses = {"precision_mod": []} + state.uses = [FortranUseStatement("precision_mod")] state.local_params = {"rk": "8", "count": "4"} state.imports = {"state_t", "callback"} state.filename = "finalize_contract.f90" @@ -104,5 +105,5 @@ def test_finalize_proc_resolves_signature_arguments_imports_and_uses_without_exp ("values", "real", "8", ["4"]), ] assert finalized.attributes == ["import(callback)", "import(state_t)"] - assert finalized.uses == {"precision_mod": []} + assert [statement.module for statement in finalized.uses] == ["precision_mod"] assert finalized.variables == {} diff --git a/tests/fortran/infrastructure/parsing/fixtures/general/assumed_shape_and_derived_args.json b/tests/fortran/infrastructure/parsing/fixtures/general/assumed_shape_and_derived_args.json index bb2d6eb98..e099a08b1 100644 --- a/tests/fortran/infrastructure/parsing/fixtures/general/assumed_shape_and_derived_args.json +++ b/tests/fortran/infrastructure/parsing/fixtures/general/assumed_shape_and_derived_args.json @@ -47,7 +47,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -91,7 +91,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -126,7 +126,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -177,7 +177,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -221,7 +221,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -256,7 +256,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] diff --git a/tests/fortran/infrastructure/parsing/fixtures/general/basic_subroutine.json b/tests/fortran/infrastructure/parsing/fixtures/general/basic_subroutine.json index 83ddbc569..da3d89d3e 100644 --- a/tests/fortran/infrastructure/parsing/fixtures/general/basic_subroutine.json +++ b/tests/fortran/infrastructure/parsing/fixtures/general/basic_subroutine.json @@ -7,7 +7,7 @@ { "name": "m1", "filename": "basic_subroutine.f90", - "uses": {}, + "uses": [], "variables": [], "procedures": [ { @@ -67,7 +67,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -95,7 +95,7 @@ "m1": { "name": "m1", "filename": "basic_subroutine.f90", - "uses": {}, + "uses": [], "variables": [], "procedures": [ { @@ -155,7 +155,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] diff --git a/tests/fortran/infrastructure/parsing/fixtures/general/compile_time_all_exprs.json b/tests/fortran/infrastructure/parsing/fixtures/general/compile_time_all_exprs.json index ce047d5c0..ad78fded7 100644 --- a/tests/fortran/infrastructure/parsing/fixtures/general/compile_time_all_exprs.json +++ b/tests/fortran/infrastructure/parsing/fixtures/general/compile_time_all_exprs.json @@ -7,7 +7,7 @@ { "name": "expr_mod", "filename": "compile_time_all_exprs.f90", - "uses": {}, + "uses": [], "variables": [ { "name": "a", @@ -452,7 +452,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -480,7 +480,7 @@ "expr_mod": { "name": "expr_mod", "filename": "compile_time_all_exprs.f90", - "uses": {}, + "uses": [], "variables": [ { "name": "a", @@ -925,7 +925,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] diff --git a/tests/fortran/infrastructure/parsing/fixtures/general/compile_time_shape_exprs.json b/tests/fortran/infrastructure/parsing/fixtures/general/compile_time_shape_exprs.json index dfe617d74..affa73cba 100644 --- a/tests/fortran/infrastructure/parsing/fixtures/general/compile_time_shape_exprs.json +++ b/tests/fortran/infrastructure/parsing/fixtures/general/compile_time_shape_exprs.json @@ -7,7 +7,7 @@ { "name": "dims_mod", "filename": "compile_time_shape_exprs.f90", - "uses": {}, + "uses": [], "variables": [ { "name": "n0", @@ -116,7 +116,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -144,7 +144,7 @@ "dims_mod": { "name": "dims_mod", "filename": "compile_time_shape_exprs.f90", - "uses": {}, + "uses": [], "variables": [ { "name": "n0", @@ -253,7 +253,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] diff --git a/tests/fortran/infrastructure/parsing/fixtures/general/derived_type.json b/tests/fortran/infrastructure/parsing/fixtures/general/derived_type.json index eeabfd7f2..086f2efd9 100644 --- a/tests/fortran/infrastructure/parsing/fixtures/general/derived_type.json +++ b/tests/fortran/infrastructure/parsing/fixtures/general/derived_type.json @@ -7,7 +7,7 @@ { "name": "particle_mod", "filename": "derived_type.f90", - "uses": {}, + "uses": [], "variables": [], "procedures": [ { @@ -40,7 +40,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -145,7 +145,7 @@ "particle_mod": { "name": "particle_mod", "filename": "derived_type.f90", - "uses": {}, + "uses": [], "variables": [], "procedures": [ { @@ -178,7 +178,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] diff --git a/tests/fortran/infrastructure/parsing/fixtures/general/derived_types_and_methods.json b/tests/fortran/infrastructure/parsing/fixtures/general/derived_types_and_methods.json index d886875fc..609631403 100644 --- a/tests/fortran/infrastructure/parsing/fixtures/general/derived_types_and_methods.json +++ b/tests/fortran/infrastructure/parsing/fixtures/general/derived_types_and_methods.json @@ -7,7 +7,7 @@ { "name": "mesh_mod", "filename": "derived_types_and_methods.f90", - "uses": {}, + "uses": [], "variables": [], "procedures": [], "derived_types": [ @@ -179,7 +179,7 @@ "mesh_mod": { "name": "mesh_mod", "filename": "derived_types_and_methods.f90", - "uses": {}, + "uses": [], "variables": [], "procedures": [], "derived_types": [ diff --git a/tests/fortran/infrastructure/parsing/fixtures/general/f77_subroutine.json b/tests/fortran/infrastructure/parsing/fixtures/general/f77_subroutine.json index 151c65f29..b9299654e 100644 --- a/tests/fortran/infrastructure/parsing/fixtures/general/f77_subroutine.json +++ b/tests/fortran/infrastructure/parsing/fixtures/general/f77_subroutine.json @@ -113,7 +113,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -230,7 +230,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] diff --git a/tests/fortran/infrastructure/parsing/fixtures/general/modern_pyi_example.json b/tests/fortran/infrastructure/parsing/fixtures/general/modern_pyi_example.json index 72d349fbc..f6c902f0b 100644 --- a/tests/fortran/infrastructure/parsing/fixtures/general/modern_pyi_example.json +++ b/tests/fortran/infrastructure/parsing/fixtures/general/modern_pyi_example.json @@ -7,7 +7,7 @@ { "name": "modern_math_physics", "filename": "modern_pyi_example.f90", - "uses": {}, + "uses": [], "variables": [ { "name": "counter", @@ -188,7 +188,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -306,7 +306,7 @@ }, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -368,7 +368,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -456,7 +456,7 @@ }, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -500,7 +500,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -535,7 +535,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -570,7 +570,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -768,7 +768,7 @@ "modern_math_physics": { "name": "modern_math_physics", "filename": "modern_pyi_example.f90", - "uses": {}, + "uses": [], "variables": [ { "name": "counter", @@ -949,7 +949,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -1067,7 +1067,7 @@ }, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -1129,7 +1129,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -1217,7 +1217,7 @@ }, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -1261,7 +1261,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -1296,7 +1296,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -1331,7 +1331,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] diff --git a/tests/fortran/infrastructure/parsing/fixtures/general/module_vars_use.json b/tests/fortran/infrastructure/parsing/fixtures/general/module_vars_use.json index 11719e60f..9cc188849 100644 --- a/tests/fortran/infrastructure/parsing/fixtures/general/module_vars_use.json +++ b/tests/fortran/infrastructure/parsing/fixtures/general/module_vars_use.json @@ -7,24 +7,22 @@ { "name": "constants_mod", "filename": "module_vars_use.f90", - "uses": { - "iso_c_binding": [ - { - "module": "iso_c_binding", - "only": true, - "mappings": [ - { - "source": "c_int", - "target": null - }, - { - "source": "c_double", - "target": null - } - ] - } - ] - }, + "uses": [ + { + "module": "iso_c_binding", + "only": true, + "mappings": [ + { + "source": "c_int", + "target": null + }, + { + "source": "c_double", + "target": null + } + ] + } + ], "variables": [ { "name": "nmax", @@ -98,24 +96,22 @@ "constants_mod": { "name": "constants_mod", "filename": "module_vars_use.f90", - "uses": { - "iso_c_binding": [ - { - "module": "iso_c_binding", - "only": true, - "mappings": [ - { - "source": "c_int", - "target": null - }, - { - "source": "c_double", - "target": null - } - ] - } - ] - }, + "uses": [ + { + "module": "iso_c_binding", + "only": true, + "mappings": [ + { + "source": "c_int", + "target": null + }, + { + "source": "c_double", + "target": null + } + ] + } + ], "variables": [ { "name": "nmax", diff --git a/tests/fortran/infrastructure/parsing/fixtures/general/procedures_and_functions.json b/tests/fortran/infrastructure/parsing/fixtures/general/procedures_and_functions.json index 8c0141157..48cec8b72 100644 --- a/tests/fortran/infrastructure/parsing/fixtures/general/procedures_and_functions.json +++ b/tests/fortran/infrastructure/parsing/fixtures/general/procedures_and_functions.json @@ -7,7 +7,7 @@ { "name": "math_mod", "filename": "procedures_and_functions.f90", - "uses": {}, + "uses": [], "variables": [], "procedures": [ { @@ -66,7 +66,7 @@ }, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -128,7 +128,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -156,7 +156,7 @@ "math_mod": { "name": "math_mod", "filename": "procedures_and_functions.f90", - "uses": {}, + "uses": [], "variables": [], "procedures": [ { @@ -215,7 +215,7 @@ }, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -277,7 +277,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] diff --git a/tests/fortran/infrastructure/parsing/fixtures/general/scope_name_reuse_combinations.json b/tests/fortran/infrastructure/parsing/fixtures/general/scope_name_reuse_combinations.json index 7df4f2e5f..34be05db6 100644 --- a/tests/fortran/infrastructure/parsing/fixtures/general/scope_name_reuse_combinations.json +++ b/tests/fortran/infrastructure/parsing/fixtures/general/scope_name_reuse_combinations.json @@ -7,7 +7,7 @@ { "name": "scope_name_reuse_combinations", "filename": "scope_name_reuse_combinations.f90", - "uses": {}, + "uses": [], "variables": [ { "name": "same_name_i", @@ -146,7 +146,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -181,7 +181,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -216,7 +216,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -251,7 +251,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -286,7 +286,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -341,7 +341,7 @@ }, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -396,7 +396,7 @@ }, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -451,7 +451,7 @@ }, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -531,7 +531,7 @@ "scope_name_reuse_combinations": { "name": "scope_name_reuse_combinations", "filename": "scope_name_reuse_combinations.f90", - "uses": {}, + "uses": [], "variables": [ { "name": "same_name_i", @@ -670,7 +670,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -705,7 +705,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -740,7 +740,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -775,7 +775,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -810,7 +810,7 @@ "result": null, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -865,7 +865,7 @@ }, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -920,7 +920,7 @@ }, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] @@ -975,7 +975,7 @@ }, "attributes": [], "bind_name": null, - "uses": {}, + "uses": [], "in_interface": false, "variables": {}, "common_variables": [] diff --git a/tests/fortran/infrastructure/parsing/test_declaration_and_interface_edges.py b/tests/fortran/infrastructure/parsing/test_declaration_and_interface_edges.py index 5e498a79f..aa8c7199a 100644 --- a/tests/fortran/infrastructure/parsing/test_declaration_and_interface_edges.py +++ b/tests/fortran/infrastructure/parsing/test_declaration_and_interface_edges.py @@ -3,7 +3,7 @@ import pytest from prik.parsers.fortran.models import FortranModule -from prik.parsers.fortran.models import FortranUseAssociation +from prik.parsers.fortran.scope import ScopeUses from prik.parsers.fortran.parser import FortranParser, _ParserScope from prik.parsers.fortran import FortranParseError, parse_fortran_file, parse_fortran_project @@ -138,15 +138,18 @@ def test_use_rename_and_intrinsic_forms_are_recorded(): module = parse_fortran_file(code).modules[0] - renamed = FortranUseAssociation.of(module.uses["list_input"]) + scope = ScopeUses(module.uses) # A rename without `only` binds the new name and still imports the rest. - assert renamed.imports_all is True - assert list(renamed.mappings) == ["delete_input"] - assert (renamed.mappings[0].source, renamed.mappings[0].target) == ("delete_input_list", "delete_input") - intrinsic = FortranUseAssociation.of(module.uses["iso_c_binding"]) - assert intrinsic.imports_all is False - assert list(intrinsic.mappings) == ["c_int", "c_double"] - assert [(item.source, item.target) for item in intrinsic.mappings] == [ + assert scope.imports_all("list_input") is True + assert list(scope.mappings("list_input")) == ["delete_input"] + assert (scope.mappings("list_input")[0].source, scope.mappings("list_input")[0].target) == ( + "delete_input_list", + "delete_input", + ) + + assert scope.imports_all("iso_c_binding") is False + assert list(scope.mappings("iso_c_binding")) == ["c_int", "c_double"] + assert [(item.source, item.target) for item in scope.mappings("iso_c_binding")] == [ ("c_int", None), ("c_double", None), ] @@ -324,7 +327,7 @@ def test_use_statement_empty_only_items_are_ignored(): module = parse_fortran_file(code, filename="use_empty_items.f90").modules[0] - assert [item.local_name for item in FortranUseAssociation.of(module.uses["constants_mod"]).mappings] == ["rk", "ik"] + assert [item.local_name for item in ScopeUses(module.uses).mappings("constants_mod")] == ["rk", "ik"] def test_type_field_spec_variants_and_empty_entities_from_public_source(): diff --git a/tests/fortran/infrastructure/parsing/test_fortran_fixture_suite.py b/tests/fortran/infrastructure/parsing/test_fortran_fixture_suite.py index 69c6aed3a..f3567aed7 100644 --- a/tests/fortran/infrastructure/parsing/test_fortran_fixture_suite.py +++ b/tests/fortran/infrastructure/parsing/test_fortran_fixture_suite.py @@ -63,7 +63,12 @@ def _strip_parent_fields(value): def _to_dict(value): - return _strip_parent_fields(asdict(value)) + """Return one parsed file as the golden records it. + + The golden is JSON, where a tuple and a list are the same array, so the + parsed model is compared in that form rather than as Python objects. + """ + return json.loads(json.dumps(_strip_parent_fields(asdict(value)))) def _dump_expected(path: Path, parsed: dict) -> None: diff --git a/tests/fortran/infrastructure/parsing/test_fortran_parser_procedures_and_interfaces.py b/tests/fortran/infrastructure/parsing/test_fortran_parser_procedures_and_interfaces.py index c12fca8e7..8ac4428b4 100644 --- a/tests/fortran/infrastructure/parsing/test_fortran_parser_procedures_and_interfaces.py +++ b/tests/fortran/infrastructure/parsing/test_fortran_parser_procedures_and_interfaces.py @@ -2,8 +2,8 @@ import pytest from prik.parsers.fortran import parse_fortran_file, parse_fortran_project +from prik.parsers.fortran.scope import ScopeUses from prik.parsers.fortran.models import ( - FortranUseAssociation, FortranFunctionCall, FortranSlice, FortranVariable, @@ -63,7 +63,7 @@ def test_function_result_and_use_statement(): assert sig.result is not None assert sig.result.name == "res" assert sig.result.base_type == "real" - assert list(FortranUseAssociation.of(sig.uses["iso_c_binding"]).mappings) == ["c_double"] + assert list(ScopeUses(sig.uses).mappings("iso_c_binding")) == ["c_double"] assert sig.arguments[0].shape == [":"] @@ -448,14 +448,14 @@ def test_submodule_module_procedure_stub_and_additional_program_units(): submodule = submodules[0] assert submodule.parent == "parent_impl" assert submodule.ancestor == "ancestor_mod" - assert list(FortranUseAssociation.of(submodule.uses["iso_c_binding"]).mappings) == ["c_int"] + assert list(ScopeUses(submodule.uses).mappings("iso_c_binding")) == ["c_int"] assert [v.name for v in submodule.variables] == ["counter"] assert [(p.name, p.kind) for p in submodule.procedures] == [("reset_counter", "module procedure")] programs = parse_fortran_programs(code) assert len(programs) == 1 assert programs[0].name == "driver" - assert FortranUseAssociation.of(programs[0].uses["ancestor_mod"]).imports_all is True + assert ScopeUses(programs[0].uses).imports_all("ancestor_mod") is True assert [v.name for v in programs[0].variables] == ["ierr"] block_data = parse_fortran_block_data(code) diff --git a/tests/fortran/modules/parsing/test_project_scope_models.py b/tests/fortran/modules/parsing/test_project_scope_models.py index d3b29490d..229a962d3 100644 --- a/tests/fortran/modules/parsing/test_project_scope_models.py +++ b/tests/fortran/modules/parsing/test_project_scope_models.py @@ -3,7 +3,7 @@ import pytest from prik.parsers.fortran import FortranParseError, parse_fortran_file, parse_fortran_project -from prik.parsers.fortran.models import FortranUseAssociation +from prik.parsers.fortran.scope import ScopeUses from prik.parsers.fortran.parser import FortranParser @@ -191,7 +191,7 @@ def test_program_contains_and_unnamed_block_data_public_models(): parsed = parse_fortran_file(code, filename="units.f90") - assert FortranUseAssociation.of(parsed.programs[0].uses["callback_mod"]).imports_all is True + assert ScopeUses(parsed.programs[0].uses).imports_all("callback_mod") is True assert [var.name for var in parsed.programs[0].variables] == ["ierr"] assert parsed.block_data_units[0].name is None assert [var.name for var in parsed.block_data_units[0].variables] == ["seed"] @@ -321,9 +321,7 @@ def test_directory_project_tracks_renamed_kind_imports_from_other_files(tmp_path assert args["x"].kind == "8" assert args["x"].shape == ["1:stride"] assert args["y"].kind == "16" - assert [ - (mapping.source, mapping.target) for mapping in FortranUseAssociation.of(proc.uses["precision_mod"]).mappings - ] == [ + assert [(mapping.source, mapping.target) for mapping in ScopeUses(proc.uses).mappings("precision_mod")] == [ ("wp", "local_wp"), ("stride", None), ("wide", "local_wide"), @@ -570,7 +568,7 @@ def test_project_resolution_uses_file_level_use_only_and_local_parameters(tmp_pa assert args["x"].kind == "selected_real_kind(12)" assert args["x"].shape == ["1:n"] assert args["y"].kind == "selected_real_kind(6)" - assert [mapping.local_name for mapping in FortranUseAssociation.of(proc.uses["public_params_mod"]).mappings] == [ + assert [mapping.local_name for mapping in ScopeUses(proc.uses).mappings("public_params_mod")] == [ "rk", "n", ] diff --git a/tests/fortran/modules/parsing/test_scope_handling.py b/tests/fortran/modules/parsing/test_scope_handling.py index 385edbdfb..cd4329458 100644 --- a/tests/fortran/modules/parsing/test_scope_handling.py +++ b/tests/fortran/modules/parsing/test_scope_handling.py @@ -2,7 +2,7 @@ from prik.parsers.fortran.models import FortranParseError from prik.parsers.fortran import parse_fortran_file -from prik.parsers.fortran.models import FortranUseAssociation +from prik.parsers.fortran.scope import ScopeUses def test_same_argument_name_in_different_procedures_is_allowed(): @@ -209,8 +209,8 @@ def test_repeated_use_of_one_module_accumulates_its_imports(): """ ).modules[0] - association = FortranUseAssociation.of(module.uses["iso_fortran_env"]) - assert [(item.source, item.target) for item in association.mappings] == [ + scope = ScopeUses(module.uses) + assert [(item.source, item.target) for item in scope.mappings("iso_fortran_env")] == [ ("INT32", None), ("REAL32", "SP"), ("REAL64", "DP"), @@ -235,9 +235,9 @@ def test_a_bare_use_is_read_beside_the_named_imports_of_the_same_module(): """ ).modules[0] - association = FortranUseAssociation.of(module.uses["kinds_mod"]) - assert association.imports_all is True - assert [(item.source, item.target) for item in association.mappings] == [("rk", None)] + scope = ScopeUses(module.uses) + assert scope.imports_all("kinds_mod") is True + assert [(item.source, item.target) for item in scope.mappings("kinds_mod")] == [("rk", None)] def test_an_empty_only_list_imports_nothing(): @@ -251,5 +251,68 @@ def test_an_empty_only_list_imports_nothing(): """ ).modules[0] - association = FortranUseAssociation.of(module.uses["kinds_mod"]) - assert (association.imports_all, association.mappings) == (False, ()) + scope = ScopeUses(module.uses) + assert (scope.imports_all("kinds_mod"), scope.mappings("kinds_mod")) == (False, ()) + + +def test_a_procedure_local_use_stays_out_of_its_module_imports(): + """A scope inherits its parent's statements; it cannot add to them. + + The procedure sees what the module imported and what it imported itself, + while the module keeps only its own -- otherwise a procedure-local import + would reach module accessibility and re-export analysis. + """ + module = parse_fortran_file( + """ +module owner_mod + use dep_mod, only : x + implicit none +contains + subroutine inner() + use dep_mod, only : y + end subroutine inner +end module owner_mod +""" + ).modules[0] + + assert [item.source for statement in module.uses for item in statement.mappings] == ["x"] + procedure = module.procedures[0] + assert [item.source for statement in procedure.uses for item in statement.mappings] == ["x", "y"] + + +def test_statements_for_one_module_are_read_whatever_their_spelling(): + """Fortran module names are case-insensitive, so both statements are one use.""" + module = parse_fortran_file( + """ +module consumer_mod + use DEP_MOD, only : p => q + use dep_mod + implicit none +end module consumer_mod +""" + ).modules[0] + + scope = ScopeUses(module.uses) + assert scope.modules() == ("DEP_MOD",) + assert scope.imports_all("dep_mod") is True + assert [(item.source, item.target) for item in scope.mappings("dep_mod")] == [("q", "p")] + + +def test_one_local_name_reached_by_two_entities_keeps_both_routes(): + """A rename may collide with a name the same module already publishes. + + `use dep, x => y` binds `y` as `x` while `x` itself still arrives, so the + local name reaches two entities. Reporting both routes is what lets the + stage holding them call that ambiguous rather than picking one. + """ + module = parse_fortran_file( + """ +module consumer_mod + use dep_mod, x => y + implicit none +end module consumer_mod +""" + ).modules[0] + + routes = ScopeUses(module.uses).routes_for("x", lambda name: {"x", "y"}) + assert sorted(route.source_name for route in routes) == ["x", "y"] diff --git a/tests/fortran/modules/semantics/test_modules_and_imports.py b/tests/fortran/modules/semantics/test_modules_and_imports.py index 2b118c2cf..fcf0f4106 100644 --- a/tests/fortran/modules/semantics/test_modules_and_imports.py +++ b/tests/fortran/modules/semantics/test_modules_and_imports.py @@ -27,10 +27,7 @@ def test_converter_normalizes_wrapped_types_and_resolves_wildcard_imports(): converter = FortranToIRConverter(wrapped_derived_types={("types_mod", "state_t")}) module = FortranModule( name="consumer", - uses={ - "OTHER_MOD": [FortranUseStatement("OTHER_MOD")], - "TYPES_MOD": [FortranUseStatement("TYPES_MOD")], - }, + uses=[FortranUseStatement("OTHER_MOD"), FortranUseStatement("TYPES_MOD")], ) context = converter._module_derived_type_context(module) @@ -39,7 +36,7 @@ def test_converter_normalizes_wrapped_types_and_resolves_wildcard_imports(): derived_type_context=context, ).semantic_type opaque_context = converter._module_derived_type_context( - FortranModule(name="consumer", uses={"OPAQUE_MOD": [FortranUseStatement("OPAQUE_MOD")]}) + FortranModule(name="consumer", uses=[FortranUseStatement("OPAQUE_MOD")]) ) opaque = converter.visit( FortranArgument(name="opaque", base_type="derived", kind="opaque_t"), From 745228ec6d6a97270a7acf102034306a4b8e428f Mon Sep 17 00:00:00 2001 From: said Date: Fri, 18 Sep 2026 13:11:24 +0100 Subject: [PATCH 74/96] codex: finish scope use resolution --- CHANGELOG.md | 4 +- docs/developer/packages/parsers.md | 2 + prik/parsers/fortran/parser.py | 2 +- prik/parsers/fortran/scope.py | 15 ++ prik/semantics/fortran2ir.py | 204 +++++++++++------- .../arrays/semantics/test_array_semantics.py | 38 +++- .../test_fortran_callback_semantics.py | 36 ++++ .../semantics/test_derived_type_identity.py | 96 ++++++++- .../modules/parsing/test_scope_handling.py | 17 ++ 9 files changed, 325 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d18ea9d1..ca9c70460 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,9 @@ release tags add a leading `v` to the package version. statements spelling one module differently (`use DEP` beside `use dep`) were also held apart, and a local name reached by two entities -- `use dep, x => y` where `dep` also publishes `x` -- silently resolved to one of them instead of - being reported ambiguous. + being reported ambiguous. Callback interfaces, declaration-expression + procedures, and derived types now reconcile the same candidate routes rather + than interpreting renames independently. - A type-bound defined assignment updates the method it names. Making a specific's identity structural left two helpers looking the original up by diff --git a/docs/developer/packages/parsers.md b/docs/developer/packages/parsers.md index 831a576eb..d2c5ec6dd 100644 --- a/docs/developer/packages/parsers.md +++ b/docs/developer/packages/parsers.md @@ -62,6 +62,7 @@ prik/parsers/ │ ├── lexer.py │ ├── models.py │ ├── parser.py +│ ├── scope.py │ ├── type_resolver.py │ └── utils.py ├── pyi/ @@ -85,6 +86,7 @@ prik/parsers/ | [`prik/parsers/fortran/utils.py`](../../../prik/parsers/fortran/utils.py) | `detect_source_form()` chooses fixed or free form; `split_csv()` separates only top-level Fortran comma lists. | Source-form detection or grammar-neutral list splitting changes. | | [`prik/parsers/fortran/lexer.py`](../../../prik/parsers/fortran/lexer.py) | `preprocess_lines()` produces logical lines with original coordinates; `strip_comment()` preserves string literals and OpenMP directives. | Comment handling, continuation folding, or location preservation changes. | | [`prik/parsers/fortran/models.py`](../../../prik/parsers/fortran/models.py) | Passive source-fact records: `FortranFile`, `FortranProject`, units, declarations, shapes, and `FortranParseError`. | A parser result, source fact, or diagnostic representation changes. | +| [`prik/parsers/fortran/scope.py`](../../../prik/parsers/fortran/scope.py) | `ScopeUses` aggregates a scope's `use` statements and is the authority for rename semantics, accessible local names, and candidate routes. Semantic consumers decide only what those routes mean for their entity category. | `use` association or scope dependency interpretation changes. | | [`prik/parsers/fortran/type_resolver.py`](../../../prik/parsers/fortran/type_resolver.py) | `extract_kind_from_type_spec()` preserves intrinsic kind and character syntax after declaration parsing. | Parser-level type-spec spelling extraction changes. | | [`prik/parsers/fortran/parser.py`](../../../prik/parsers/fortran/parser.py) | `FortranParser`, `parse_fortran_file()`, and `parse_fortran_project()` build file and project models. | Grammar, source-unit structure, declarations, parser diagnostics, or project assembly changes. | | [`prik/parsers/fortran/cli.py`](../../../prik/parsers/fortran/cli.py) | `main()` formats parser reports and diagnostics. Its `--semantics` and `--pyi` options explicitly invoke later stages. | Parser CLI arguments, report layout, or diagnostic presentation changes. | diff --git a/prik/parsers/fortran/parser.py b/prik/parsers/fortran/parser.py index 66d4bcdfe..26e0c51e1 100644 --- a/prik/parsers/fortran/parser.py +++ b/prik/parsers/fortran/parser.py @@ -5273,7 +5273,7 @@ def _imported_compile_time_symbols( @staticmethod def _compile_time_symbols_for_scope( owner_name: str | None, - uses: Mapping[str, list[FortranUseMapping]], + uses: Iterable[FortranUseStatement], symbols: _CompileTimeSymbols, ) -> dict[str, str]: """Return a mutable flat symbol map visible to one parsed scope. diff --git a/prik/parsers/fortran/scope.py b/prik/parsers/fortran/scope.py index 3c6b79dc4..b64d23027 100644 --- a/prik/parsers/fortran/scope.py +++ b/prik/parsers/fortran/scope.py @@ -108,6 +108,21 @@ def accessible_names(self, offered: OfferedNames) -> tuple[str, ...]: names.setdefault(name.casefold(), name) return tuple(names.values()) + def unresolved_routes_for(self, local_name: str, offered: OfferedNames) -> tuple[UseRoute, ...]: + """Return possible whole-module routes whose names cannot be enumerated. + + A plain ``use`` of a module this project never read carries names none + of which can be listed. The route stays possible unless a rename took + this spelling away; the consuming entity category decides whether that + uncertainty makes the name ambiguous or permits an opaque fallback. + """ + folded = local_name.casefold() + return tuple( + UseRoute(module, local_name) + for module in self.modules() + if self.imports_all(module) and offered(module) is None and folded not in self._renamed_away(module) + ) + def _renamed_away(self, module: str) -> frozenset[str]: """Return the names a rename reaches, which are not reachable as written.""" return frozenset(item.source.casefold() for item in self.mappings(module) if item.target) diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 2f1dcf2e1..e4e8e3c5b 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -361,6 +361,7 @@ def __init__( self.wrapped_derived_types = { (str(module).lower(), str(name).lower()) for module, name in (wrapped_derived_types or []) } + self._known_modules: set[str] = {module for module, _name in self.wrapped_derived_types} self._known_procedures: set[tuple[str, str]] = set() self.type_facts = { (str(base_type).lower(), None if kind is None else str(kind).lower()): dict(fact) @@ -419,10 +420,12 @@ def _visit_FortranFile( 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)) + siblings = tuple(sibling_modules) + converter = self._with_additional_known_modules(module.name for module in (*siblings, *parsed_file.modules)) + converter = converter._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)) - index = self._callback_module_index(sibling_modules, parsed_file.modules) + index = self._callback_module_index(siblings, parsed_file.modules) modules = [converter.visit(module, module_index=index) for module in parsed_file.modules] if parsed_file.procedures: modules.append( @@ -442,13 +445,14 @@ def _visit_FortranProject(self, project: FortranProject) -> list[SemanticModule] while imported callback interfaces are resolved against the project. The returned module ordering matches the input file and parser order. """ - 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), ) + converter = self._with_additional_known_modules(module.name for module in index.values()) + converter = converter._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)) semantic_modules = [] for parsed_file in project.files: file_converter = converter._with_additional_wrapped_types(converter._wrapped_types_from_file(parsed_file)) @@ -887,39 +891,58 @@ def _merge_imported_callback_interfaces( readable route answer for it. """ declared_here = set(visible) - candidates: dict[str, set[tuple[str | None, str] | None]] = {} scope = ScopeUses(uses) - for module_name in scope.modules(): - source_module = modules.get(module_name.casefold()) - if source_module is None: - for mapping in scope.mappings(module_name): - candidates.setdefault(mapping.local_name.casefold(), set()).add(None) + exported = { + module_name: cls._module_callback_interfaces(modules, source, seen=seen, exported_only=True) + for module_name in scope.modules() + if (source := modules.get(module_name.casefold())) is not None + } + for name in scope.accessible_names(lambda module: exported.get(module)): + reached = [ + cls._reached_callback_interface(route, exported) + for route in scope.routes_for(name, lambda module: exported.get(module)) + ] + resolved = cls._one_reached_interface(reached, local_name=name) + if resolved is None: + if name.casefold() not in declared_here: + visible.pop(name.casefold(), None) continue - source_lookup = cls._module_callback_interfaces( - modules, - source_module, - seen=seen, - exported_only=True, - ) - imported: dict[str, _CallbackInterface] = {} - for mapping in scope.mappings(module_name): - resolved = source_lookup.get(mapping.source.casefold()) - if resolved is not None: - # A rename binds the interface under the name the importing - # scope gives it, which a contract here has to state. - imported[mapping.local_name.casefold()] = replace(resolved, local_name=mapping.local_name) - if scope.imports_all(module_name): - for name, resolved in source_lookup.items(): - imported.setdefault(name, resolved) - for name, resolved in imported.items(): - candidates.setdefault(name, set()).add(cls._callback_identity(resolved)) - if override: - visible[name] = resolved - else: - visible.setdefault(name, resolved) - for name, identities in candidates.items(): - if name not in declared_here and len(identities) > 1: - visible.pop(name, None) + if override: + visible[name.casefold()] = resolved + else: + visible.setdefault(name.casefold(), resolved) + + @staticmethod + def _reached_callback_interface( + route: UseRoute, + exported: dict[str, dict[str, _CallbackInterface]], + ) -> _CallbackInterface | None: + """Return the interface one route reaches, or ``None`` for an unread module.""" + lookup = exported.get(route.module) + return None if lookup is None else lookup.get(route.source_name.casefold()) + + @classmethod + def _one_reached_interface( + cls, + reached: list[_CallbackInterface | None], + *, + local_name: str, + ) -> _CallbackInterface | None: + """Return the one interface a local name reaches, or ``None``. + + Routes are compared by the declaration they reach, so repeating a route + to one interface is harmless while two different ones leave the name + meaning nothing here. A route into a module this project never read + offers whatever it names, which nothing here can compare, so it makes + the name unresolved rather than letting a readable route answer for it. + """ + identities = {None if item is None else cls._callback_identity(item) for item in reached} + if len(identities) != 1: + return None + resolved = next((item for item in reached if item is not None), None) + # The importing scope may bind the interface under another spelling, + # which a contract written here has to state. + return None if resolved is None else replace(resolved, local_name=local_name) @staticmethod def _callback_identity(resolved: _CallbackInterface) -> tuple[str | None, str]: @@ -2248,37 +2271,32 @@ def _resolve_declaration_callable( ) scope = ScopeUses(context.uses) - explicit = [ - (module_name, mapping.source) - for module_name in scope.modules() - for mapping in scope.mappings(module_name) - if mapping.local_name.casefold() == key - ] - if len(explicit) == 1: - return SemanticExpressionCallable( - name=name, - native_name=explicit[0][1], - native_scope=explicit[0][0], - source_language="fortran", - placement="module", - ) - if explicit: - return None - - wildcard_modules = [name for name in scope.modules() if scope.imports_all(name)] - known_origins = [ - module_name for module_name in wildcard_modules if (module_name.casefold(), key) in self._known_procedures - ] - if len(known_origins) != 1: + offered = self._known_procedure_names() + routes = scope.routes_for(name, offered) + if len({route.key for route in routes}) != 1: return None return SemanticExpressionCallable( name=name, - native_name=name, - native_scope=known_origins[0], + native_name=routes[0].source_name, + native_scope=routes[0].module, source_language="fortran", placement="module", ) + def _known_procedure_names(self): + """Return the procedure names each module declares, or ``None``.""" + by_module: dict[str, set[str]] = {} + for module_name, procedure_name in self._known_procedures: + by_module.setdefault(module_name.casefold(), set()).add(procedure_name.casefold()) + + def offered(module_name: str): + key = module_name.casefold() + if key in self._known_modules: + return by_module.get(key, set()) + return None + + return offered + def _with_additional_wrapped_types( self, wrapped_types: Iterable[tuple[str, str]], @@ -2302,6 +2320,24 @@ def _with_additional_wrapped_types( ) converter._known_procedures = set(self._known_procedures) converter._abstract_derived_types = set(self._abstract_derived_types) + converter._known_modules |= self._known_modules + return converter + + def _with_additional_known_modules(self, modules: Iterable[str]) -> FortranToIRConverter: + """Return this converter or a clone that also knows parsed modules.""" + merged = self._known_modules | {str(module).casefold() for module in modules} + if merged == self._known_modules: + return self + converter = FortranToIRConverter( + type_map=self.type_map, + compile_time_values=self.compile_time_values, + wrapped_derived_types=self.wrapped_derived_types, + type_facts=self.type_facts, + assume_intent_in_scalars=self.assume_intent_in_scalars, + ) + converter._known_modules = merged + converter._known_procedures = set(self._known_procedures) + converter._abstract_derived_types = set(self._abstract_derived_types) return converter def _with_additional_known_procedures( @@ -2323,6 +2359,7 @@ def _with_additional_known_procedures( ) converter._known_procedures = merged converter._abstract_derived_types = set(self._abstract_derived_types) + converter._known_modules = self._known_modules | {module for module, _name in merged} return converter def _with_additional_abstract_types( @@ -2344,6 +2381,7 @@ def _with_additional_abstract_types( ) converter._known_procedures = set(self._known_procedures) converter._abstract_derived_types = merged + converter._known_modules = self._known_modules | {module for module, _name in merged} return converter @staticmethod @@ -2527,33 +2565,33 @@ def _resolve_derived_type_origin_from_uses( imports intentionally remain unresolved so this conversion stage does not invent a native identity. """ - lname = local_name.lower() - explicit: list[tuple[str, str]] = [] - wildcard_modules: list[str] = [] scope = ScopeUses(uses or ()) - for module_name in scope.modules(): - if scope.imports_all(module_name): - wildcard_modules.append(module_name) - for mapping in scope.mappings(module_name): - if mapping.local_name.lower() == lname: - explicit.append((module_name, mapping.source)) - - if len(explicit) == 1: - return _ResolvedDerivedTypeOrigin(explicit[0][0], explicit[0][1]) - if len(explicit) > 1: + offered = self._wrapped_type_names() + routes = scope.routes_for(local_name, offered) + identities = {route.key for route in routes} + if len(identities) == 1: + return _ResolvedDerivedTypeOrigin(routes[0].module, routes[0].source_name) + if identities: return _ResolvedDerivedTypeOrigin(None, local_name) - - wrapped_wildcards = [ - module_name - for module_name in wildcard_modules - if (module_name.lower(), lname) in self.wrapped_derived_types - ] - if len(wrapped_wildcards) == 1: - return _ResolvedDerivedTypeOrigin(wrapped_wildcards[0], local_name) - if len(wildcard_modules) == 1: - return _ResolvedDerivedTypeOrigin(wildcard_modules[0], local_name) + unresolved = scope.unresolved_routes_for(local_name, offered) + if len({route.key for route in unresolved}) == 1: + return _ResolvedDerivedTypeOrigin(unresolved[0].module, unresolved[0].source_name) return _ResolvedDerivedTypeOrigin(None, local_name) + def _wrapped_type_names(self): + """Return the wrapped type names each module declares, or ``None``.""" + by_module: dict[str, set[str]] = {} + for module_name, type_name in self.wrapped_derived_types: + by_module.setdefault(module_name.casefold(), set()).add(type_name.casefold()) + + def offered(module_name: str): + key = module_name.casefold() + if key in self._known_modules: + return by_module.get(key, set()) + return None + + return offered + def _semantic_type_name(self, var: FortranVariable) -> str: """Map a parsed intrinsic, derived, or procedure declaration to its dtype name. diff --git a/tests/fortran/arrays/semantics/test_array_semantics.py b/tests/fortran/arrays/semantics/test_array_semantics.py index a885444c9..e4a908e59 100644 --- a/tests/fortran/arrays/semantics/test_array_semantics.py +++ b/tests/fortran/arrays/semantics/test_array_semantics.py @@ -249,9 +249,7 @@ def test_wildcard_specification_function_origin_round_trips_unambiguously(): end module extent_helpers module unrelated_helpers -contains -subroutine unrelated() -end subroutine unrelated + integer, parameter :: unrelated = 1 end module unrelated_helpers module expression_owner @@ -279,6 +277,40 @@ def test_wildcard_specification_function_origin_round_trips_unambiguously(): assert reloaded_array.expression_callables == array.expression_callables +def test_non_only_rename_does_not_choose_between_specification_function_routes(): + """Ambiguous and renamed-away procedure names keep no invented origin.""" + source = """ +module extent_helpers +contains +integer function x(n) result(extent) + integer, intent(in) :: n + extent = n +end function x +integer function y(n) result(extent) + integer, intent(in) :: n + extent = n +end function y +end module extent_helpers + +module expression_owner + use extent_helpers, x => y +contains +function values(n) result(output) + integer, intent(in) :: n + real(8) :: output(x(n), y(n)) +end function values +end module expression_owner +""" + modules = fortran_file_to_semantic_modules(parse_fortran_source(source)) + module = next(item for item in modules if item.name == "expression_owner") + callables = get_function(module, "values").return_type.storage.array.expression_callables + + assert callables == [ + [SemanticExpressionCallable(name="x", native_name="x", source_language="fortran")], + [SemanticExpressionCallable(name="y", native_name="y", source_language="fortran")], + ] + + def test_unindexed_wildcard_specification_function_origin_is_not_guessed(): source = """ module expression_owner diff --git a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py index 5c6a18ffe..034c0db7a 100644 --- a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py +++ b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py @@ -458,6 +458,42 @@ def test_procedure_local_rename_keeps_both_the_declared_and_local_names(): } +def test_non_only_rename_does_not_choose_between_callback_routes(): + """A renamed interface and the same local spelling remain ambiguous.""" + source = """ +module callback_types + abstract interface + subroutine x(value) + real, intent(in) :: value + end subroutine x + subroutine y(value) + integer, intent(in) :: value + end subroutine y + end interface +end module callback_types + +module callback_user + use callback_types, x => y +contains + subroutine apply_x(callback) + procedure(x) :: callback + end subroutine apply_x + subroutine apply_y(callback) + procedure(y) :: callback + end subroutine apply_y +end module callback_user +""" + modules = {module.name: module for module in FortranToIRConverter().visit(parse_fortran_source(source))} + + callback_x = get_function(modules["callback_user"], "apply_x").arguments[0].semantic_type + callback_y = get_function(modules["callback_user"], "apply_y").arguments[0].semantic_type + + assert callback_x.name == "Procedure" + assert callback_x.metadata[UNRESOLVED_PROCEDURE_INTERFACE_METADATA] == "x" + assert callback_y.name == "Procedure" + assert callback_y.metadata[UNRESOLVED_PROCEDURE_INTERFACE_METADATA] == "y" + + def test_interface_reference_uses_the_declared_spelling(): """Fortran matches names case-insensitively; Python contracts do not. diff --git a/tests/fortran/derived_types/semantics/test_derived_type_identity.py b/tests/fortran/derived_types/semantics/test_derived_type_identity.py index c855d47aa..36834dff8 100644 --- a/tests/fortran/derived_types/semantics/test_derived_type_identity.py +++ b/tests/fortran/derived_types/semantics/test_derived_type_identity.py @@ -1,6 +1,9 @@ """Tests split by stable ownership concept from `test_compile_time_values.py`.""" -from prik.semantics.fortran2ir import fortran_module_to_semantic_module +from prik.semantics.fortran2ir import ( + fortran_file_to_semantic_modules, + fortran_module_to_semantic_module, +) from tests.fortran._support.semantic_conversion import get_function from prik.parsers.fortran import parse_fortran_file as parse_fortran_source @@ -30,3 +33,94 @@ def test_procedure_local_derived_type_rename_uses_origin_type_identity(): "representation": "opaque", "import_scope": "procedure", } + + +def test_non_only_rename_does_not_choose_between_derived_type_routes(): + """An ambiguous name and a renamed-away name have no invented type owner.""" + parsed = parse_fortran_source( + """ +module types_mod + type :: x + integer :: value + end type x + type :: y + integer :: value + end type y +end module types_mod + +module consumer + use types_mod, x => y +contains + subroutine take_x(value) + type(x), intent(in) :: value + end subroutine take_x + subroutine take_y(value) + type(y), intent(in) :: value + end subroutine take_y +end module consumer +""" + ) + modules = {module.name: module for module in fortran_file_to_semantic_modules(parsed)} + + type_x = get_function(modules["consumer"], "take_x").arguments[0].semantic_type + type_y = get_function(modules["consumer"], "take_y").arguments[0].semantic_type + + assert type_x.name == "x" + assert "external_type_ref" not in type_x.metadata + assert type_y.name == "y" + assert "external_type_ref" not in type_y.metadata + + +def test_unindexed_non_only_rename_does_not_resurrect_the_source_spelling(): + parsed = parse_fortran_source( + """ +module consumer + use unavailable_types, x => y +contains + subroutine take_x(value) + type(x), intent(in) :: value + end subroutine take_x + subroutine take_y(value) + type(y), intent(in) :: value + end subroutine take_y +end module consumer +""" + ) + module = fortran_module_to_semantic_module(parsed) + + type_x = get_function(module, "take_x").arguments[0].semantic_type + type_y = get_function(module, "take_y").arguments[0].semantic_type + + assert type_x.metadata["external_type_ref"]["origin_module"] == "unavailable_types" + assert type_x.metadata["external_type_ref"]["name"] == "y" + assert "external_type_ref" not in type_y.metadata + + +def test_parsed_module_without_types_is_not_an_opaque_type_route(): + parsed = parse_fortran_source( + """ +module types_mod + type :: point + integer :: value + end type point +end module types_mod + +module constants_mod + integer, parameter :: count = 1 +end module constants_mod + +module consumer + use types_mod, only : point + use constants_mod +contains + subroutine take(value) + type(point), intent(in) :: value + end subroutine take +end module consumer +""" + ) + modules = {module.name: module for module in fortran_file_to_semantic_modules(parsed)} + + point = get_function(modules["consumer"], "take").arguments[0].semantic_type + + assert point.metadata["external_type_ref"]["origin_module"] == "types_mod" diff --git a/tests/fortran/modules/parsing/test_scope_handling.py b/tests/fortran/modules/parsing/test_scope_handling.py index cd4329458..e5d15c0f7 100644 --- a/tests/fortran/modules/parsing/test_scope_handling.py +++ b/tests/fortran/modules/parsing/test_scope_handling.py @@ -316,3 +316,20 @@ def test_one_local_name_reached_by_two_entities_keeps_both_routes(): routes = ScopeUses(module.uses).routes_for("x", lambda name: {"x", "y"}) assert sorted(route.source_name for route in routes) == ["x", "y"] + + +def test_unread_whole_module_routes_still_apply_rename_semantics(): + """Unknown offered names stay possible except under a renamed-away spelling.""" + module = parse_fortran_file( + """ +module consumer_mod + use dep_mod, x => y + implicit none +end module consumer_mod +""" + ).modules[0] + scope = ScopeUses(module.uses) + + assert [route.source_name for route in scope.unresolved_routes_for("x", lambda name: None)] == ["x"] + assert scope.unresolved_routes_for("y", lambda name: None) == () + assert [route.source_name for route in scope.unresolved_routes_for("z", lambda name: None)] == ["z"] From ffc26cac82f7838e39e418590e494449df40a4f5 Mon Sep 17 00:00:00 2001 From: said Date: Fri, 18 Sep 2026 15:25:34 +0100 Subject: [PATCH 75/96] Complete contract spelling once, before emission Naming a contract's declarations was decided in four places: export policy chose published names, the printer allocated the rest while rendering and kept `settled_names`, `published_names`, `published_specifics`, `reexport_names` and `class_python_names` to remember what it had chosen, class-surface policy ran its own `NamingPolicy` for members, and `published_names()` rendered a whole module just to learn what it would be called. Those four could disagree, and every per-stage test still passed. `_complete_contract_names()` now records a `CONTRACT_NAME_METADATA` on every declaration -- published or withheld, class members and nested classes included -- plus the spelling each overload target names. The printer and class-surface construction read it through `completed_contract_name()`, and the printer keeps no naming state beyond the purely textual aliases for `prik.contracts` imports. That reader sits beside its key in `semantics/models.py`, because a printer may not import policy. Finishing it surfaced four problems the old arrangement had hidden: - A build folds every source module into one, so a single root ledger made four facades each re-exporting `scale_value` collide. Each name is now held where its own authority places it: a published declaration in its completed export namespace, a re-export in its publisher's, a withheld declaration in the file being completed. A declaration's native module is not that file -- a facade carries its generic's inherited specifics -- so it is not used. - A user class named `Vector` was printed as `prik.contracts.Vector`. The old code avoided it only because the semantic type still carried the lowercase Fortran name; a completed spelling is now a declaration, never a symbol. - One procedure can be declared at module level and as the method binding it, and the `.pyi` reader resolves an overload target against module procedures first. Targets are written in that same order, so the contract names the declaration its reader finds -- the other one routes a private procedure the bridge cannot reach. - Completion walked only top-level classes, leaving nested classes' members unnamed. Two class-naming assertions change from `PointType`/`point_t` to `Pointtype`/`Point_T`. Those tests emitted without completing policy, so the printer had printed the raw semantic name -- a name no build published; the runtime export was already `Pointtype`/`Point_T`, and the contract now agrees. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 6 + docs/developer/packages/naming.md | 15 +- docs/developer/packages/policy.md | 15 +- docs/developer/packages/printers.md | 9 +- prik/naming/policy.py | 22 ++ prik/pipeline/pyi.py | 15 +- prik/policy/completion.py | 1 - prik/policy/construction.py | 50 +-- prik/policy/exports.py | 302 ++++++++++++++++- prik/printers/pyi.py | 316 ++++-------------- prik/semantics/models.py | 18 + .../test_generated_generic_contracts.py | 8 +- .../scope_name_reuse_combinations.json | 1 + .../test_contract_publication_round_trip.py | 34 +- .../test_pyi_printer_imports_and_packages.py | 44 +-- .../pipeline/test_types_and_declarations.py | 2 + .../semantics/test_declaration_publication.py | 1 - 17 files changed, 512 insertions(+), 347 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca9c70460..71283c9da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ release tags add a leading `v` to the package version. ## Unreleased +- Contract spelling is now completed once in post-IR policy for every + declaration, including withheld helpers and class members. Generated + contracts, cross-module import spelling, and class-surface policy read that + decision directly; contract emission no longer allocates names or renders a + module to discover them. + - A scope's `use` statements are kept as a flat, immutable list and read by one resolver. A procedure inheriting its module's imports could previously append to the very list the module held, so `use dep, only : y` written inside a diff --git a/docs/developer/packages/naming.md b/docs/developer/packages/naming.md index 448ec98e7..1de087d34 100644 --- a/docs/developer/packages/naming.md +++ b/docs/developer/packages/naming.md @@ -19,10 +19,13 @@ choose exports, ownership, wrapper support, or emitted syntax. ## The Two Naming Routes ```text -source spelling + public namespace +source spelling + contract namespace -> normalize Python identifier -> reserve it or add a collision suffix - -> public export name + -> completed contract spelling + +completed contract spelling + publication policy + -> zero or more public export placements owner identity + preferred generated name + target rules -> escape reserved or special names @@ -30,7 +33,7 @@ owner identity + preferred generated name + target rules -> deterministic native symbol ``` -Public names and generated symbols are deliberately separate. Escaping a +Contract names and generated symbols are deliberately separate. Escaping a Python keyword must not rename the underlying Fortran symbol, and a C or Fortran restriction must not change the public Python API. @@ -54,7 +57,11 @@ prik/naming/ `NativeSymbolNames.compact()`. It combines a readable prefix with a hash of the full owner identity under a requested length limit. -`NamingPolicy` retains public reservations for one construction operation. +`NamingPolicy` retains contract-namespace reservations for one policy +completion operation. Post-IR policy records the selected spelling on semantic +owners; contract emission and class-surface construction read that result and +do not create their own reservation ledgers. Publication is separate: a +withheld declaration still has a contract spelling so annotations can name it. `NativeSymbolNames` is stateless: the same owner, preferred spelling, and limit always produce the same result. diff --git a/docs/developer/packages/policy.md b/docs/developer/packages/policy.md index 517117ef0..2e2035383 100644 --- a/docs/developer/packages/policy.md +++ b/docs/developer/packages/policy.md @@ -57,7 +57,7 @@ downstream fallback. | [`prik/policy/__init__.py`](../../../prik/policy/__init__.py) | Re-exports `complete_semantic_policies()` as the normal policy-stage entrypoint. | The supported policy import surface changes. | | [`prik/policy/models.py`](../../../prik/policy/models.py) | Immutable records and enums for function, argument, result, slot, lifecycle, class, overload, callback, array, descriptor, status, and transformation policy. | A completed decision needs a durable backend-neutral representation. | | [`prik/policy/ownership.py`](../../../prik/policy/ownership.py) | Ownership vocabulary, `OwnershipContext`, `OwnershipDecision`, `OwnershipPolicyResolver`, and action dispatchers resolve lifetime triples and fail-closed lowering actions. | Object kind, owner, transfer, destruction, storage, barrier, assignment, or setter selection changes. | -| [`prik/policy/exports.py`](../../../prik/policy/exports.py) | `PythonExportPolicy`, `complete_python_export_policy()`, and `completed_python_exports()` create collision-checked Python placement. | Export namespace, visibility, or collision behavior changes. | +| [`prik/policy/exports.py`](../../../prik/policy/exports.py) | `complete_python_export_policy()` completes collision-checked contract spellings and Python placement; focused readers expose those recorded decisions. | Contract naming, export namespace, visibility, or collision behavior changes. | | [`prik/policy/construction.py`](../../../prik/policy/construction.py) | Feature constructors build coherent function, result, native-slot, callback, class, overload, and module-variable policies from completed ownership decisions. | A supported feature needs different completed policy composition. | | [`prik/policy/completion.py`](../../../prik/policy/completion.py) | `complete_semantic_policies()` runs the dependency-ordered completion pass, attaches outcomes, and validates blockers. | Completion order, cross-declaration completion, or the stage boundary changes. | | [`prik/policy/native_array_handles.py`](../../../prik/policy/native_array_handles.py) | `NativeArrayHandlePolicy`, ABI selectors and dispatchers, and `native_array_handle_build_requirements()` describe already-completed descriptor handles and their build requirements. | Descriptor-backed array ABI selection, allowed operations, dispatch, or build headers change. | @@ -158,10 +158,15 @@ that boundary. ### `exports.py` and `native_array_handles.py`: focused completion products -`complete_python_export_policy()` writes one collision-checked Python name for -each public declaration in its namespace. `completed_python_exports()` reads -those names as immutable `PythonExportPolicy` records while assembling a -wrapper policy. +`complete_python_export_policy()` writes one collision-checked contract +spelling for every declaration, including withheld helpers and class members, +then records zero or more public placements independently. +`completed_python_exports()` reads the placements as immutable +`PythonExportPolicy` records while assembling wrapper policy. The contract +spelling is read with `completed_contract_name()`, which lives beside +`CONTRACT_NAME_METADATA` in `prik/semantics/models.py` so contract emission can +read the decision without importing policy; class-surface construction reads it +the same way. `completion.py` creates native-array handle policies for descriptor-backed arrays. `native_array_handles.py` carries those records through the rest of diff --git a/docs/developer/packages/printers.md b/docs/developer/packages/printers.md index a7fcc443c..3bb26d6bb 100644 --- a/docs/developer/packages/printers.md +++ b/docs/developer/packages/printers.md @@ -41,7 +41,7 @@ SemanticModule graph -> PyiPrinter -> editable .pyi | [`prik/printers/__init__.py`](../../../prik/printers/__init__.py) | Re-exports `CSourcePrinter`, `FortranSourcePrinter`, `PyiPrinter`, and `emit_module()`. | The supported printer import surface changes. | | [`prik/printers/c.py`](../../../prik/printers/c.py) | `CSourcePrinter` serializes C translation units, headers, declarations, functions, tables, and statements. | C syntax layout, escaping, or formatting changes. | | [`prik/printers/fortran.py`](../../../prik/printers/fortran.py) | `FortranSourcePrinter` serializes bridge modules, interfaces, declarations, procedures, and free-form wrapped statements. | Fortran source layout or line-wrapping changes. | -| [`prik/printers/pyi.py`](../../../prik/printers/pyi.py) | `PyiPrinter`, `emit_module()`, and `_PyiEmissionContext` serialize semantic modules and scope imports, aliases, namespaces, and defaults for one emission. | Editable contract spelling or emission-context behavior changes. | +| [`prik/printers/pyi.py`](../../../prik/printers/pyi.py) | `PyiPrinter`, `emit_module()`, and `_PyiEmissionContext` serialize semantic modules and scope imports, aliases, namespaces, and defaults for one emission. | Editable contract rendering or emission-context behavior changes. | The fact that code generation calls a printer at the end of wrapper rendering does not make printing part of codegen ownership. `pipeline/wrapper.py` @@ -77,9 +77,10 @@ unsplittable line that remains above the 132-column compiler-safe limit. ### `pyi.py`: semantic IR to an editable contract `PyiPrinter.emit()` creates a fresh `_PyiEmissionContext` for every call. The -context records contract imports, aliases, public-name reservations, source -array defaults, and nested namespaces without mutating a reusable printer or -the semantic IR. +context records contract imports, aliases, source array defaults, and nested +namespaces without mutating a reusable printer or the semantic IR. Contract +spellings and overload-target spellings must already be completed on semantic +owners by post-IR policy; the printer reads them and keeps no naming allocator. For a module, the printer first renders public classes, prototypes, variables, functions, and overload sets into body sections. As visitors use contract diff --git a/prik/naming/policy.py b/prik/naming/policy.py index ec3ec748b..bf9573eea 100644 --- a/prik/naming/policy.py +++ b/prik/naming/policy.py @@ -151,6 +151,28 @@ def reserve_public_name( reserved[name] = PublicNameRecord(raw_text, category, str(owner or raw_name)) return name + def hold_completed_public_name( + self, + namespace: tuple[str, ...], + name: object, + *, + category: str, + owner: object | None = None, + ) -> str: + """Hold an already-completed spelling without interpreting it again.""" + completed = str(name) + namespace_key = tuple(str(part) for part in namespace) + reserved = self._public_names.setdefault(namespace_key, {}) + existing = reserved.get(completed) + if existing is not None: + namespace_text = ".".join(namespace_key) or "" + raise ValueError( + f"Completed public {category} name {completed!r} in {namespace_text} collides with " + f"{existing.category} {existing.raw_name!r} ({existing.owner})" + ) + reserved[completed] = PublicNameRecord(completed, category, str(owner or name)) + return completed + def has_generated_symbol_clash(self, name: object, symbols: set[object], *, language: str) -> bool: """Return whether ``name`` is unusable in the selected language.""" return generated_symbol_rules(language).has_clash(name, symbols) diff --git a/prik/pipeline/pyi.py b/prik/pipeline/pyi.py index 08c6f4a7d..de4904708 100644 --- a/prik/pipeline/pyi.py +++ b/prik/pipeline/pyi.py @@ -16,8 +16,8 @@ from prik.parsers.pyi import parse_pyi_text from prik.policy.completion import complete_semantic_policies -from prik.policy.exports import complete_python_export_policy -from prik.printers.pyi import PyiPrinter, emit_module +from prik.policy.exports import complete_python_export_policy, contract_names_by_source +from prik.printers.pyi import emit_module from prik.semantics.models import EXTERNAL_TYPE_REF_METADATA, SemanticClass, SemanticModule, _module_semantic_types from prik.semantics.pyi_metadata import PYI_LOADED_METADATA from prik.semantics.pyi2ir import convert_pyi_to_ir, reconcile_external_type_refs @@ -160,18 +160,17 @@ def emit_module_stubs( for reexport in module.reexports if reexport.entity_kind == "prototype" } - # What a contract publishes a name under is settled by rendering it, so - # every module is named once before any of them writes an import. - naming_printer = PyiPrinter(normalize_public_names=normalize_public_names) - published_names_by_module = { - module_name: naming_printer.published_names(module) for module_name, module in naming_modules.items() + # Import emission reads the spelling post-IR policy completed for every + # declaration, including withheld helpers another contract may reference. + contract_names_by_module = { + module_name: contract_names_by_source(module) for module_name, module in naming_modules.items() } return { module_name: emit_module( module, normalize_public_names=normalize_public_names, declared_prototype_names=declared_prototype_names, - published_names_by_module=published_names_by_module, + contract_names_by_module=contract_names_by_module, ).strip() for module_name, module in emitted_modules.items() } diff --git a/prik/policy/completion.py b/prik/policy/completion.py index 2ba02d1fe..cf1e43209 100644 --- a/prik/policy/completion.py +++ b/prik/policy/completion.py @@ -547,7 +547,6 @@ def _complete_class_surface_policies( owner_path=derived.owner_path, derived=derived, class_identities=identities, - strict_wrapper_names=strict_wrapper_names, ) completed_derived = replace(derived, fields=surface.effective_fields) semantic_class.metadata[models.RESOLVED_DERIVED_TYPE_POLICY_METADATA] = completed_derived diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 850fc9a54..7e002e886 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -20,7 +20,6 @@ from immutabledict import immutabledict from prik.contracts import NATIVE_C_SCALAR_IDENTITIES -from prik.naming import NamingPolicy, preserves_source_case from prik.utilities.declaration_expressions import fortran_character_value from prik.semantics import models from prik.semantics.metadata import ( @@ -480,17 +479,15 @@ def build_class_surface_policy( owner_path: str, derived: DerivedTypePolicy, class_identities: dict[str, tuple[str, str]], - strict_wrapper_names: bool = False, ) -> ClassSurfacePolicy: """Complete constructor, method, inheritance, and registration decisions.""" - naming = NamingPolicy( - strict_public_names=strict_wrapper_names, - preserve_case=preserves_source_case(semantic_class.origin.source_language), - ) - fields = _python_named_class_fields(derived.fields, naming, owner_path) + # Contract-name completion already applied strict naming and one shared + # member ledger. Class policy reads those spellings rather than allocating + # a second surface whose collision order could disagree with the contract. + fields = _python_named_class_fields(semantic_class, derived.fields, owner_path) named_derived = replace(derived, fields=fields) - methods = _python_named_class_methods(semantic_class, naming, owner_path) - overloads = _python_named_class_overloads(semantic_class, naming, owner_path) + methods = _python_named_class_methods(semantic_class, owner_path) + overloads = _python_named_class_overloads(semantic_class, owner_path) constructor, constructor_blockers = _class_constructor_policy( semantic_class, owner_path=owner_path, @@ -527,21 +524,16 @@ def build_class_surface_policy( def _python_named_class_fields( + semantic_class: models.SemanticClass, fields: tuple[DerivedFieldPolicy, ...], - naming: NamingPolicy, owner_path: str, ) -> tuple[DerivedFieldPolicy, ...]: - """Reserve readable Python field names while retaining native spellings.""" - namespace = (owner_path,) + """Read completed field names while retaining native owner identities.""" + completed = {f"{owner_path}.{field.name}": models.completed_contract_name(field) for field in semantic_class.fields} return tuple( replace( field, - name=naming.reserve_public_name( - namespace, - field.name, - category="field", - owner=field.owner_path, - ), + name=completed[field.owner_path], ) for field in fields ) @@ -549,11 +541,9 @@ def _python_named_class_fields( def _python_named_class_methods( semantic_class: models.SemanticClass, - naming: NamingPolicy, owner_path: str, ) -> tuple[ClassMethodPolicy, ...]: - """Reserve method names in the same Python namespace as public fields.""" - namespace = (owner_path,) + """Read method names completed in the same namespace as public fields.""" methods = [] for method in semantic_class.methods: if method.name == "__init__": @@ -562,12 +552,7 @@ def _python_named_class_methods( if policy.public: policy = replace( policy, - python_name=naming.reserve_public_name( - namespace, - policy.python_name, - category="function", - owner=policy.owner_path, - ), + python_name=models.completed_contract_name(method), ) methods.append(policy) return tuple(methods) @@ -575,11 +560,9 @@ def _python_named_class_methods( def _python_named_class_overloads( semantic_class: models.SemanticClass, - naming: NamingPolicy, owner_path: str, ) -> tuple[OverloadPolicy, ...]: - """Split reflected operators, then reserve every public overload name.""" - namespace = (owner_path,) + """Split reflected operators and read every completed overload name.""" policies = [] for overload in semantic_class.overload_sets: names = tuple( @@ -598,12 +581,7 @@ def _python_named_class_overloads( policies.append( replace( policy, - python_name=naming.reserve_public_name( - namespace, - policy.python_name, - category="function", - owner=policy.owner_path, - ), + python_name=models.completed_contract_name(procedures[0]), ) ) return tuple(policies) diff --git a/prik/policy/exports.py b/prik/policy/exports.py index c94127b83..09327e6d7 100644 --- a/prik/policy/exports.py +++ b/prik/policy/exports.py @@ -1,14 +1,14 @@ -"""Resolve Python export names for later wrapper-policy construction. +"""Resolve contract spellings and Python exports before later stages run. -``complete_python_export_policy`` walks public semantic declarations in their -lowering order, normalizes their requested names, and reserves one name in each -Python namespace. It writes the completed names back to semantic metadata so -all policy constructors see the same collision-checked result. +``complete_python_export_policy`` walks semantic declarations in lowering +order, completes public placement, then records the collision-checked spelling +the generated contract declares for every owner. Withheld declarations and +class members still need a contract identity even when they publish nothing. ``completed_python_exports`` retrieves that metadata as immutable ``PythonExportPolicy`` records while wrapper policy is assembled. This module -decides Python placement only: it does not choose a wrapper mechanism or emit -the namespace. +decides Python placement and contract spelling only: it does not choose a +wrapper mechanism or emit the namespace. """ from __future__ import annotations @@ -98,6 +98,11 @@ def complete_python_export_policy( ) export["name"] = resolved_name _complete_reexport_names(module, naming, contract_named=contract_named) + _complete_contract_names( + module, + strict_wrapper_names=strict_wrapper_names, + contract_named=contract_named, + ) #: Entity kinds a second namespace cannot publish, whatever it may reach. @@ -202,18 +207,29 @@ def _completed_variable_reexport_name( return None -def _reexport_namespace(module: models.SemanticModule, reexport: models.SemanticReexport) -> tuple[str, ...]: - """Return the Python namespace one re-export publishes into. +def _placement_namespace(module: models.SemanticModule, scope: object) -> tuple[str, ...]: + """Return the namespace a name written by module ``scope`` is placed in. - A re-export names the module publishing it. Completing that same module - names it against the module's own root, which is where its declarations - are; completing a merged package instead names it inside the namespace - that module occupies there, beside the declarations it sits with. + Completing that same module places it at the module's own root. Completing + a merged package -- a build folds every source module into one -- places it + inside the namespace that module occupies there, beside the declarations it + sits with, so names from two modules never compete for one spelling. """ - publisher = str(reexport.module or "") - if not publisher or publisher.casefold() == str(module.name).casefold(): + declaring = str(scope or "") + if not declaring or declaring.casefold() == str(module.name).casefold(): return () - return tuple(part.casefold() for part in publisher.split(".") if part) + return tuple(part.casefold() for part in declaring.split(".") if part) + + +def _reexport_namespace(module: models.SemanticModule, reexport: models.SemanticReexport) -> tuple[str, ...]: + """Return the Python namespace one re-export publishes into: its publisher's.""" + return _placement_namespace(module, reexport.module) + + +def _declaring_namespace(module: models.SemanticModule, owner) -> tuple[str, ...]: + """Return the namespace of the module one declaration is written in.""" + scope = owner.native_scope if isinstance(owner, models.ProcedureOverloadSet) else owner.origin.native_scope + return _placement_namespace(module, scope) def _module_export_owners(module: models.SemanticModule): @@ -221,6 +237,260 @@ def _module_export_owners(module: models.SemanticModule): return (*module.classes, *module.functions, *module.overload_sets, *module.variables) +def _complete_contract_names( + module: models.SemanticModule, + *, + strict_wrapper_names: bool, + contract_named: bool, +) -> None: + """Record every declaration spelling consumed by contract emission. + + A name has to be unique among the names written in one contract, so each + is held where it is placed: a published declaration in the namespace its + export completed, a re-export in its publisher's, a withheld declaration + in the file being completed. A build folds every source module into one, + and placing by those authorities keeps two modules' names apart there. + Published spellings are held first, so a withheld helper cannot move a + public API aside; each class then gets one member ledger, shared with + class-surface policy. + """ + preserve_case = contract_named or preserves_source_case(module.origin.source_language) + naming = NamingPolicy(strict_public_names=strict_wrapper_names, preserve_case=preserve_case) + owners = _module_export_owners(module) + + for owner in owners: + placed = _own_export(module, owner) + if placed is None: + continue + namespace, completed = placed + naming.hold_completed_public_name( + namespace, + completed, + category=_owner_category(owner), + owner=f"{_owner_category(owner)} {owner.name}", + ) + owner.metadata[models.CONTRACT_NAME_METADATA] = completed + + # Imports bind names in the same contract namespace as declarations. Their + # export spelling was already settled against public declarations; holding + # it here prevents a withheld declaration from taking the binding. + for reexport in module.reexports: + if reexport.python_name: + naming.hold_completed_public_name( + _reexport_namespace(module, reexport), + reexport.python_name, + category="function", + owner=f"re-export {reexport.local_name}", + ) + + for prototype in module.prototypes: + completed = str(prototype.name) + naming.hold_completed_public_name( + _declaring_namespace(module, prototype), + completed, + category="function", + owner=f"prototype {prototype.native_name or prototype.name}", + ) + prototype.metadata[models.CONTRACT_NAME_METADATA] = completed + + # A withheld declaration is written in the file being completed, whatever + # module declared it natively: a generic's inherited specifics are carried + # into the facade that extends it, beside each other. + for owner in owners: + if owner.metadata.get(models.CONTRACT_NAME_METADATA) is not None: + continue + owner.metadata[models.CONTRACT_NAME_METADATA] = naming.reserve_public_name( + (), + owner.name, + category=_owner_category(owner), + owner=f"{_owner_category(owner)} {owner.name}", + ) + + for semantic_class in module.classes: + _complete_class_member_contract_names( + semantic_class, + (*_declaring_namespace(module, semantic_class), models.completed_contract_name(semantic_class)), + strict_wrapper_names=strict_wrapper_names, + preserve_case=preserve_case, + ) + + _complete_local_type_contract_names(module) + _complete_overload_target_contract_names(module, preserve_case=preserve_case) + + +def _own_export(module: models.SemanticModule, owner) -> tuple[tuple[str, ...], str] | None: + """Return the namespace and spelling one declaration is published under at home. + + Its home is the namespace of the module it is written in, or the root of + the file being completed; an export elsewhere is a second publication of + it, which names nothing in its own contract. + """ + home = {(), _declaring_namespace(module, owner)} + for export in _owner_metadata(owner).get(models.PYTHON_EXPORTS_METADATA, ()) or (): + if not isinstance(export, dict) or export.get("name") is None: + continue + namespace = tuple(part.casefold() for part in export_namespace(export)) + if namespace in home: + return namespace, str(export["name"]) + return None + + +def _all_classes(classes: list[models.SemanticClass]): + """Yield every class, each followed by the classes nested inside it.""" + for semantic_class in classes: + yield semantic_class + yield from _all_classes(semantic_class.classes) + + +def _complete_class_member_contract_names( + semantic_class: models.SemanticClass, + namespace: tuple[str, ...], + *, + strict_wrapper_names: bool, + preserve_case: bool, +) -> None: + """Complete one class's field, method, and overload spellings once.""" + naming = NamingPolicy(strict_public_names=strict_wrapper_names, preserve_case=preserve_case) + for field in semantic_class.fields: + field.metadata[models.CONTRACT_NAME_METADATA] = naming.reserve_public_name( + namespace, + field.name, + category="field", + owner=field.name, + ) + for method in semantic_class.methods: + if method.name.startswith("__"): + method.metadata[models.CONTRACT_NAME_METADATA] = method.name + continue + method.metadata[models.CONTRACT_NAME_METADATA] = naming.reserve_public_name( + namespace, + method.name, + category="function", + owner=method.name, + ) + for overload in semantic_class.overload_sets: + source_names = tuple( + dict.fromkeys( + str(procedure.metadata.get(models.PYTHON_METHOD_NAME_METADATA, overload.name)) + for procedure in overload.procedures + ) + ) or (str(overload.name),) + for source_name in source_names: + completed = naming.reserve_public_name( + namespace, + source_name, + category="function", + owner=source_name, + ) + overload.metadata.setdefault(models.CONTRACT_NAME_METADATA, completed) + for procedure in overload.procedures: + procedure_name = str(procedure.metadata.get(models.PYTHON_METHOD_NAME_METADATA, overload.name)) + if procedure_name == source_name: + procedure.metadata[models.CONTRACT_NAME_METADATA] = completed + # A nested class is written inside its parent, so it is named among the + # parent's members and its own members get a ledger beneath that name. + for nested in semantic_class.classes: + nested.metadata[models.CONTRACT_NAME_METADATA] = naming.reserve_public_name( + namespace, + nested.name, + category="class", + owner=nested.name, + ) + _complete_class_member_contract_names( + nested, + (*namespace, models.completed_contract_name(nested)), + strict_wrapper_names=strict_wrapper_names, + preserve_case=preserve_case, + ) + + +def _complete_local_type_contract_names(module: models.SemanticModule) -> None: + """Attach local class spellings to every semantic type that names one.""" + by_exact = {str(cls.name): models.completed_contract_name(cls) for cls in _all_classes(module.classes)} + by_folded: dict[str, list[str]] = {} + for source, completed in by_exact.items(): + by_folded.setdefault(source.casefold(), []).append(completed) + for semantic_type in models._module_semantic_types(module): + completed = by_exact.get(str(semantic_type.name)) + if completed is None: + matches = by_folded.get(str(semantic_type.name).casefold(), ()) + completed = matches[0] if len(matches) == 1 else None + if completed is not None: + semantic_type.metadata[models.CONTRACT_NAME_METADATA] = completed + for semantic_class in _all_classes(module.classes): + semantic_class.metadata[models.CONTRACT_BASE_NAMES_METADATA] = { + base: by_exact.get(base, base) for base in semantic_class.base_classes + } + + +def _complete_overload_target_contract_names( + module: models.SemanticModule, + *, + preserve_case: bool, +) -> None: + """Resolve overload targets to the contract spelling of their specific. + + A target is written the way the contract's reader resolves it: against the + module's own procedures first, then the methods of the type whose generic + it is. One procedure can be declared both ways -- ``counter_add_integer`` + at module level, ``add_integer`` as the method binding it -- and they are + reached differently, so the contract has to name the one its reader finds. + """ + _name_overload_targets(module.overload_sets, (module.functions,), preserve_case=preserve_case) + for semantic_class in _all_classes(module.classes): + _name_overload_targets( + semantic_class.overload_sets, + (module.functions, semantic_class.methods), + preserve_case=preserve_case, + ) + + +def _name_overload_targets( + overloads: list[models.ProcedureOverloadSet], + specific_groups: tuple[list[models.SemanticFunction], ...], + *, + preserve_case: bool, +) -> None: + """Record, on each candidate, the spelling its specific is declared under.""" + by_identity: dict[tuple[str, str], str] = {} + by_source: dict[str, str] = {} + for specifics in specific_groups: + for specific in specifics: + identity = _specific_identity(specific) + if identity is not None: + by_identity.setdefault(identity, models.completed_contract_name(specific)) + by_source.setdefault(str(specific.name), models.completed_contract_name(specific)) + for overload in overloads: + for candidate in overload.procedures: + target = str( + candidate.metadata.get(models.OVERLOAD_TARGET_METADATA) or candidate.native_name or candidate.name + ) + scope = str(candidate.origin.native_scope or "").casefold() + completed = by_identity.get((scope, target.casefold())) or by_source.get(target) + if completed is None: + completed = normalize_public_name(target, preserve_case=preserve_case).name + candidate.metadata[models.CONTRACT_TARGET_NAME_METADATA] = completed + + +def _specific_identity(function: models.SemanticFunction) -> tuple[str, str] | None: + """Return the native declaration identity used by an overload target.""" + scope = str(function.origin.native_scope or "") + native = str(function.native_name or function.name) + if not scope or not native: + return None + return scope.casefold(), native.casefold() + + +def contract_names_by_source(module: models.SemanticModule) -> dict[str, str]: + """Return source spellings mapped to the names this contract declares.""" + names = {str(owner.name): models.completed_contract_name(owner) for owner in _module_export_owners(module)} + names.update((str(prototype.name), models.completed_contract_name(prototype)) for prototype in module.prototypes) + names.update( + (str(reexport.local_name), str(reexport.python_name or reexport.local_name)) for reexport in module.reexports + ) + return names + + def _owner_metadata(owner) -> dict[str, object]: """Return the metadata mapping that owns one export policy.""" if isinstance(owner, models.ProcedureOverloadSet): diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index 2309f138e..f9a1e51ff 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -18,8 +18,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, preserves_source_case +from prik.naming.policy import normalize_public_name from prik.utilities.declaration_expressions import fortran_character_value, outside_character_literals from prik.semantics.scalar_types import SEMANTIC_SCALAR_TYPE_NAMES from prik.semantics.ownership_metadata import ( @@ -41,14 +40,15 @@ USER_PRIVATE_METADATA, ) from prik.semantics.models import ( + CONTRACT_BASE_NAMES_METADATA, + CONTRACT_NAME_METADATA, + CONTRACT_TARGET_NAME_METADATA, EXTERNAL_TYPE_REF_METADATA, FORTRAN_GENERIC_NAME_METADATA, OVERLOAD_KIND_METADATA, OVERLOAD_TARGET_METADATA, NATIVE_BY_VALUE_METADATA, PYTHON_BOUND_POSITION_METADATA, - PYTHON_EXPORTS_METADATA, - export_namespace, PYTHON_METHOD_NAME_METADATA, PYTHON_STATIC_METADATA, PYTHON_VALUE_IMMUTABLE, @@ -68,6 +68,7 @@ SemanticDestructor, SemanticFunction, SemanticImport, + completed_contract_name, SemanticImportItem, SemanticMethod, SemanticModule, @@ -97,43 +98,7 @@ class _PyiEmissionContext: semantic_class_names: frozenset[str] = frozenset() contract_aliases: dict[str, str] = field(default_factory=dict) contract_imports: set[str] = field(default_factory=set) - naming_policy: NamingPolicy = field(default_factory=NamingPolicy) - reserved_public_names: dict[tuple[tuple[str, ...], str, object], str] = field(default_factory=dict) public_namespace: tuple[str, ...] = () - reexport_names: dict[str, str] = field(default_factory=dict) - """Each re-exported source name, casefolded, to the name policy completed. - - An import binding a re-exported name writes what export policy settled, so - the import cannot bind a name one of this module's declarations holds. - """ - class_python_names: dict[str, str] = field(default_factory=dict) - """Each wrapped type's source name to the spelling this contract declares it under. - - A declaration and every annotation naming it read the same entry, so an - annotation cannot refer to a class the contract never declares. - """ - settled_names: dict[tuple[str, str], str] = field(default_factory=dict) - """Module-level names post-IR policy completed, keyed by category and source name. - - Policy owns every public name a build publishes, and a contract describing - that build states the same ones. The emission reads them from here rather - than allocating a second set, whose ordering and collision suffixes would - be its own and could attach the same names to different declarations. - """ - published_names: dict[str, str] = field(default_factory=dict) - """Source name, casefolded, to the spelling this contract published it under. - - Collision handling can move a name aside, so what a declaration is finally - called is knowable only from the emission that named it. A module reading - from this one asks for the published spelling rather than deriving one. - """ - published_specifics: dict[tuple[str, str], str] = field(default_factory=dict) - """Declaring scope and native name to the spelling this contract wrote. - - A merged generic dispatches over specifics from more than one module, which - may spell one the same way, so an overload target naming only that spelling - names no single declaration. The scope completes the identity. - """ def contract(self, name: str) -> str: """Return one local contract spelling and record its required import.""" @@ -146,47 +111,12 @@ def contract_type(self, name: str) -> str: """Return the local spelling for one contract type name.""" if name in CONTRACT_TYPE_NAMES: return self.contract(name) - return self.class_python_names.get(str(name), name) + return name def inside_class(self, name: str) -> _PyiEmissionContext: """Return a child namespace view sharing this emission's accumulators.""" return replace(self, public_namespace=(*self.public_namespace, name)) - def public_name(self, raw_name: str, *, category: str, owner: object) -> str: - """Reserve and return one normalized name inside the current namespace.""" - key = (self.public_namespace, category, self._public_owner_key(owner)) - reserved = self.reserved_public_names.get(key) - if reserved is not None: - return reserved - public_name = self.naming_policy.reserve_public_name( - self.public_namespace, - raw_name, - category=category, - owner=raw_name, - ) - self.reserved_public_names[key] = public_name - return self.publish(raw_name, public_name) - - def settled(self, category: str, raw_name: object) -> str | None: - """Return the completed name for one module-level declaration, if any. - - A class member is named by class-surface policy, which only a build - request completes, so inside a class there is nothing to read here. - """ - if self.public_namespace: - return None - return self.settled_names.get((category, str(raw_name))) - - def publish(self, raw_name: object, public_name: str) -> str: - """Record the spelling this contract published one name under.""" - if not self.public_namespace: - self.published_names.setdefault(str(raw_name), public_name) - return public_name - - def normalized(self, raw_name: object) -> str: - """Return one name under this emission's naming rule, reserving nothing.""" - return normalize_public_name(raw_name, preserve_case=self.naming_policy.preserve_case).name - def contract_import(self) -> str: """Return the direct import for contract symbols used by this emission.""" if not self.contract_imports: @@ -197,16 +127,9 @@ def contract_import(self) -> str: items.append(f"{name} as {alias}" if alias else name) return f"from {_CONTRACT_MODULE} import {', '.join(items)}" - @staticmethod - def _public_owner_key(owner: object) -> object: - """Return a stable cache key for one emitted public declaration.""" - if isinstance(owner, str | int | tuple): - return owner - return id(owner) - -def published_name(published: dict[str, str] | None, source: object) -> str | None: - """Return the spelling a contract published one source name under. +def contract_name_for_source(completed: dict[str, str] | None, source: object) -> str | None: + """Return the completed contract spelling for one source name. A contract records each name exactly as its source spells it, so two declarations a case-sensitive language keeps apart keep separate entries. @@ -215,14 +138,14 @@ def published_name(published: dict[str, str] | None, source: object) -> str | No names no single declaration, and guessing one would depend on the order they happened to be recorded in. """ - if not published: + if not completed: return None wanted = str(source) - exact = published.get(wanted) + exact = completed.get(wanted) if exact is not None: return exact folded = wanted.casefold() - matches = [value for key, value in published.items() if key.casefold() == folded] + matches = [value for key, value in completed.items() if key.casefold() == folded] return matches[0] if len(matches) == 1 else None @@ -246,7 +169,7 @@ def __init__( *, normalize_public_names: bool = False, declared_prototype_names: Iterable[tuple[str, str]] = (), - published_names_by_module: dict[str, dict[str, str]] | None = None, + contract_names_by_module: dict[str, dict[str, str]] | None = None, ): """Configure public-name normalization for independent emissions. @@ -258,14 +181,16 @@ def __init__( 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. + module may spell an ordinary declaration the same way. Pass + contract_names_by_module when imports must read contract spellings + completed for modules rendered in the same operation. """ self._normalize_public_names = normalize_public_names self._declared_prototype_names = { (str(module).casefold(), str(name).casefold()): str(name) for module, name in declared_prototype_names } - self._published_names_by_module = { - str(module).casefold(): dict(names) for module, names in (published_names_by_module or {}).items() + self._contract_names_by_module = { + str(module).casefold(): dict(names) for module, names in (contract_names_by_module or {}).items() } def emit(self, node) -> str: @@ -279,59 +204,14 @@ def emit(self, node) -> str: context = self._emission_context(node) return self._visit(node, context) - def published_names(self, module: SemanticModule) -> dict[str, str]: - """Return the spelling this module's contract publishes each name under. - - Rendering is what settles a name, because a collision can move one - aside, so the module is rendered and only its naming kept. A prototype - is published as it is declared and never renamed. - """ - context = self._emission_context(module) - self._visit(module, context) - names = dict(context.published_names) - for prototype in module.prototypes: - if self._is_private(prototype): - continue - names[str(prototype.name)] = str(prototype.name) - for reexport in module.reexports: - if reexport.entity_kind == "prototype" and reexport.publishes_to_python(): - names[str(reexport.local_name)] = str(reexport.local_name) - return names - def _emission_context(self, node) -> _PyiEmissionContext: """Build isolated state for one public emission call.""" if not isinstance(node, SemanticModule): return _PyiEmissionContext( normalize_public_names=self._normalize_public_names, ) - # Post-IR policy owns every module-level public name; the emission reads - # them. The allocator below names only what policy does not reach: the - # members of a class, whose names class-surface policy completes for a - # build request alone. - naming_policy = NamingPolicy(preserve_case=preserves_source_case(node.origin.source_language)) - settled_names = self._completed_public_names(node) - for (category, _source_name), public_name in settled_names.items(): - # Hold every completed name in the allocator as well, so a - # declaration policy never named -- a private one, which no build - # publishes -- cannot be handed a name that is already spoken for. - naming_policy.reserve_public_name((), public_name, category=category, owner=public_name) return _PyiEmissionContext( normalize_public_names=self._normalize_public_names, - naming_policy=naming_policy, - settled_names=settled_names, - # A contract read back from .pyi keeps every spelling verbatim, so - # there is nothing to map and both the declaration and every - # annotation naming it fall through to the source name. - class_python_names=( - {str(cls.name): settled_names.get(("class", str(cls.name)), str(cls.name)) for cls in node.classes} - if self._normalize_public_names - else {} - ), - reexport_names={ - str(reexport.local_name).casefold(): reexport.python_name - for reexport in node.reexports - if reexport.python_name - }, default_array_order=self._native_default_array_order(node.origin.source_language), semantic_class_names=frozenset( str(cls.name) @@ -341,44 +221,6 @@ def _emission_context(self, node) -> _PyiEmissionContext: contract_aliases=self._contract_aliases_for_module(node), ) - @staticmethod - def _completed_public_names(module: SemanticModule) -> dict[tuple[str, str], str]: - """Return the module-level names post-IR policy completed. - - The categories and the metadata location match - ``prik.policy.exports``, which is the owner: an overload set records - its export on its first procedure. Only an export that publishes a - declaration as this module's own names what is written here -- a build - gives a Fortran module's members that module's namespace and a - standalone procedure the root one, while a re-export elsewhere names a - different namespace and is not what this file declares. - """ - own_namespaces = {(), (str(module.name).casefold(),)} - owners = ( - *((cls, "class") for cls in module.classes), - *((func, "function") for func in module.functions), - *((overload_set, "function") for overload_set in module.overload_sets), - *((variable, "variable") for variable in module.variables), - ) - settled: dict[tuple[str, str], str] = {} - for owner, category in owners: - for export in PyiPrinter._owner_exports(owner): - namespace = tuple(part.casefold() for part in export_namespace(export)) - if export.get("name") is None or namespace not in own_namespaces: - continue - settled[(category, str(owner.name))] = str(export["name"]) - break - return settled - - @staticmethod - def _owner_exports(owner: object) -> tuple[dict, ...]: - """Return one declaration's completed Python export records.""" - if isinstance(owner, ProcedureOverloadSet): - metadata = owner.procedures[0].metadata if owner.procedures else {} - else: - metadata = getattr(owner, "metadata", {}) or {} - return tuple(item for item in metadata.get(PYTHON_EXPORTS_METADATA, ()) or () if isinstance(item, dict)) - @staticmethod def _visit_not_supported(node): """Reject semantic models that have no `.pyi` visitor.""" @@ -564,19 +406,13 @@ def _overload_target_name(candidate: SemanticFunction, context: _PyiEmissionCont target = str(candidate.metadata.get(OVERLOAD_TARGET_METADATA) or candidate.native_name or candidate.name) if not context.normalize_public_names: return target - # The specific was named while this same contract was rendered, and a - # collision may have moved that name aside, so the naming it settled on - # is what the target has to state. A merged generic may dispatch over - # specifics two modules spell alike, so the scope declaring this one - # picks out which declaration the target means. - scope = str(getattr(candidate.origin, "native_scope", "") or "").casefold() - by_identity = ( - context.published_specifics.get((scope, target.casefold())) if not context.public_namespace else None - ) - if by_identity is not None: - return by_identity - published = published_name(context.published_names, target) - return published or context.normalized(target) + completed = candidate.metadata.get(CONTRACT_TARGET_NAME_METADATA) + if completed is None: + raise ValueError( + f"Contract overload target for {target!r} is incomplete; " + "run complete_python_export_policy before emission" + ) + return str(completed) def _visit_ProcedureOverloadSet( self, @@ -600,6 +436,7 @@ def _visit_ProcedureOverloadSet( indent = " " else: candidate.name = self._overload_set_name(overload_set, context) + candidate.metadata[CONTRACT_NAME_METADATA] = candidate.name definition = self._emit_function( candidate, context, @@ -637,7 +474,7 @@ def _visit_SemanticClass( ) -> str: """Emit class syntax.""" bases = ( - f"({', '.join(self._class_base_text(base, context) for base in cls.base_classes)})" + f"({', '.join(self._class_base_text(cls, base, context) for base in cls.base_classes)})" if cls.base_classes else "" ) @@ -668,9 +505,11 @@ def _visit_SemanticClass( """.strip() @staticmethod - def _class_base_text(base: str, context: _PyiEmissionContext) -> str: + def _class_base_text(cls: SemanticClass, base: str, context: _PyiEmissionContext) -> str: """Return an imported contract base name or a user base name.""" - return context.contract_type(base) + completed = cls.metadata.get(CONTRACT_BASE_NAMES_METADATA, {}) if context.normalize_public_names else {} + name = completed.get(base, base) if isinstance(completed, dict) else base + return context.contract_type(str(name)) @staticmethod def _is_abstract(cls: SemanticClass) -> bool: @@ -841,7 +680,13 @@ def _semantic_base_type( is always spelled so the two are never confused. """ if semantic_type.name != "String": - return context.contract_type(semantic_type.name) + completed = semantic_type.metadata.get(CONTRACT_NAME_METADATA) if context.normalize_public_names else None + if completed is not None: + # The type names a declaration this contract writes, which is + # that declaration even when it is spelled like a contract + # symbol: a user class `Vector` is not `prik.contracts.Vector`. + return str(completed) + return context.contract_type(str(semantic_type.name)) length = semantic_type.metadata.get("fortran_character_length") string = context.contract("String") if length is None or str(length) in {"", "*"}: @@ -1700,6 +1545,9 @@ def _module_reserved_names(cls, module: SemanticModule) -> set[str]: @classmethod def _collect_reserved_item_names(cls, item: object, names: set[str]) -> None: """Collect emitted declaration names that can shadow imports.""" + metadata = getattr(item, "metadata", None) + if isinstance(metadata, dict) and metadata.get(CONTRACT_NAME_METADATA): + names.add(str(metadata[CONTRACT_NAME_METADATA])) for attr in ("name", "native_name"): value = getattr(item, attr, None) if isinstance(value, str) and value: @@ -1749,6 +1597,11 @@ def _append_imports( sections.append(contract_import) imports = self._effective_imports(module) verbatim = self._verbatim_import_names(module) + reexport_names = { + str(reexport.local_name).casefold(): str(reexport.python_name) + for reexport in module.reexports + if reexport.python_name + } for imp in imports: sections.append( self._emit_import( @@ -1756,8 +1609,8 @@ def _append_imports( native_source=not module.metadata.get(PYI_LOADED_METADATA), public_names=context.normalize_public_names, verbatim_names=verbatim, - published_names_by_module=self._published_names_by_module, - reexport_names=context.reexport_names, + contract_names_by_module=self._contract_names_by_module, + reexport_names=reexport_names, ) ) if contract_import or imports: @@ -2023,7 +1876,7 @@ def _validate_procedure_namespace_imports( def _top_level_declaration_names(module: SemanticModule) -> set[str]: """Return names emitted in a module-level stub namespace.""" return { - str(item.name) + str(getattr(item, "metadata", {}).get(CONTRACT_NAME_METADATA, item.name)) for item in [ *module.classes, *module.prototypes, @@ -2092,7 +1945,7 @@ def _emit_import( native_source: bool = False, public_names: bool = False, verbatim_names: dict[tuple[str, str], str] | None = None, - published_names_by_module: dict[str, dict[str, str]] | None = None, + contract_names_by_module: dict[str, dict[str, str]] | None = None, reexport_names: dict[str, str] | None = None, ) -> str: """Emit import syntax.""" @@ -2101,14 +1954,14 @@ def _emit_import( if not imp.items: return f"import {imp.module}" source_module = imp.module.lstrip(".").casefold() - published_names = (published_names_by_module or {}).get(source_module) + contract_names = (contract_names_by_module or {}).get(source_module) items = ", ".join( PyiPrinter._emit_import_item( item, public_names=public_names, verbatim_names=verbatim_names, source_module=source_module, - published_names=published_names, + contract_names=contract_names, reexport_names=reexport_names, ) for item in imp.items @@ -2123,7 +1976,7 @@ def _emit_import_item( public_names: bool = False, verbatim_names: dict[tuple[str, str], str] | None = None, source_module: str = "", - published_names: dict[str, str] | None = None, + contract_names: dict[str, str] | None = None, reexport_names: dict[str, str] | None = None, ) -> str: """Emit import item syntax. @@ -2153,8 +2006,8 @@ def _emit_import_item( # company when a rename says so, and also when a collision moved the # published name aside. source = PyiPrinter._public_import_name(item.source, public_names=public_names) - if public_names and published_names: - source = published_name(published_names, item.source) or source + if public_names and contract_names: + source = contract_name_for_source(contract_names, item.source) or source # A name this module publishes is bound under the name export policy # completed for it, which a collision with one of this module's own # declarations may have moved aside. @@ -2473,33 +2326,9 @@ def _callable_name( owner: object | None = None, ) -> str: """Return the Python-visible callable name to write in the contract.""" - if not context.normalize_public_names or func.name.startswith("__"): + if not context.normalize_public_names: return func.name - settled = context.settled("function", func.name) - name = ( - context.publish(func.name, settled) - if settled is not None - else context.public_name( - func.name, - category="method" if isinstance(func, SemanticMethod) else "function", - owner=owner if owner is not None else func, - ) - ) - # Only a module-level declaration is named this way, exactly as - # `publish` records one: a class member is named inside its class. - identity = PyiPrinter._specific_identity(func) if not context.public_namespace else None - if identity is not None: - context.published_specifics.setdefault(identity, name) - return name - - @staticmethod - def _specific_identity(func: SemanticFunction) -> tuple[str, str] | None: - """Return the scope and native name identifying one declaration, if known.""" - scope = str(getattr(func.origin, "native_scope", "") or "") - native = str(func.native_name or func.name) - if not scope or not native: - return None - return scope.casefold(), native.casefold() + return completed_contract_name(func) @staticmethod def _reexport_name(reexport: SemanticReexport, context: _PyiEmissionContext) -> str: @@ -2512,26 +2341,24 @@ def _reexport_name(reexport: SemanticReexport, context: _PyiEmissionContext) -> local = str(reexport.local_name) if not context.normalize_public_names: return local - return context.publish(local, reexport.python_name or local) + if not reexport.python_name: + raise ValueError( + f"Contract name for re-export {local!r} is incomplete; " + "run complete_python_export_policy before emission" + ) + return str(reexport.python_name) @staticmethod def _class_name(cls: SemanticClass, context: _PyiEmissionContext) -> str: """Return the Python-visible class name to write in the contract.""" - emitted = context.class_python_names.get(str(cls.name), str(cls.name)) - return context.publish(cls.name, emitted) + return completed_contract_name(cls) if context.normalize_public_names else str(cls.name) @staticmethod def _overload_set_name(overload_set: ProcedureOverloadSet, context: _PyiEmissionContext) -> str: """Return the Python-visible name of one module-level overload set.""" if not context.normalize_public_names: return str(overload_set.name) - settled = context.settled("function", overload_set.name) - if settled is not None: - return context.publish(overload_set.name, settled) - # The dispatcher and the name written for it are one declaration, so - # both reserve under the identity the emission uses. Asking as two - # owners would hand the definition a second, deduplicated spelling. - return context.public_name(overload_set.name, category="function", owner=("overload", overload_set.name)) + return completed_contract_name(overload_set) @staticmethod def _data_member_name( @@ -2541,7 +2368,7 @@ def _data_member_name( """Return the Python-visible class data-member name.""" if not context.normalize_public_names: return variable.name - return context.public_name(variable.name, category="field", owner=variable) + return completed_contract_name(variable) @staticmethod def _module_variable_name( @@ -2551,10 +2378,7 @@ def _module_variable_name( """Return the Python-visible module variable name.""" if not context.normalize_public_names: return variable.name - settled = context.settled("variable", variable.name) - if settled is not None: - return context.publish(variable.name, settled) - return context.public_name(variable.name, category="variable", owner=variable) + return completed_contract_name(variable) def _decorators( self, @@ -3114,22 +2938,22 @@ def emit_module( *, normalize_public_names: bool = False, declared_prototype_names: Iterable[tuple[str, str]] = (), - published_names_by_module: dict[str, dict[str, str]] | None = None, + contract_names_by_module: dict[str, dict[str, str]] | None = None, ) -> str: """Render one semantic module through the shared default printer. Use this convenience entrypoint for ordinary one-module emission. Set normalize_public_names when the module is named in its own source language rather than in Python, declared_prototype_names to name the prototypes the - modules rendered alongside this one declare, and published_names_by_module + modules rendered alongside this one declare, and contract_names_by_module to state the spelling each of those modules published its names under. Every path creates a fresh module emission context. """ - if normalize_public_names or declared_prototype_names or published_names_by_module: + if normalize_public_names or declared_prototype_names or contract_names_by_module: return PyiPrinter( normalize_public_names=normalize_public_names, declared_prototype_names=declared_prototype_names, - published_names_by_module=published_names_by_module, + contract_names_by_module=contract_names_by_module, ).emit(module) return _DEFAULT_PRINTER.emit(module) diff --git a/prik/semantics/models.py b/prik/semantics/models.py index ac9364984..7e69b5d48 100644 --- a/prik/semantics/models.py +++ b/prik/semantics/models.py @@ -414,6 +414,8 @@ class ProcedureOverloadSet: reads this rather than assuming a generic is public. """ + metadata: dict[str, Any] = field(default_factory=dict) + FORTRAN_GENERIC_NAME_METADATA = "fortran_generic_name" OVERLOAD_KIND_METADATA = "overload_kind" @@ -421,6 +423,22 @@ class ProcedureOverloadSet: PYTHON_BOUND_POSITION_METADATA = "python_bound_position" PYTHON_METHOD_NAME_METADATA = "python_method_name" PYTHON_EXPORTS_METADATA = "python_exports" +CONTRACT_NAME_METADATA = "contract_name" +CONTRACT_TARGET_NAME_METADATA = "contract_target_name" +CONTRACT_BASE_NAMES_METADATA = "contract_base_names" + + +def completed_contract_name(owner, default_name: str | None = None) -> str: + """Return the spelling contract-name completion recorded for one declaration. + + This reads the decision and never makes it: an owner completion did not + reach is an error, because naming it here would be a second authority. + """ + completed = owner.metadata.get(CONTRACT_NAME_METADATA) + if completed is None: + name = default_name if default_name is not None else getattr(owner, "name", None) + raise ValueError(f"Contract name for {name!r} is incomplete; run complete_python_export_policy before emission") + return str(completed) def export_namespace(export: dict[str, object]) -> tuple[str, ...]: 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 aeea6c5bc..f6666d612 100644 --- a/tests/fortran/generic_interfaces/pipeline/test_generated_generic_contracts.py +++ b/tests/fortran/generic_interfaces/pipeline/test_generated_generic_contracts.py @@ -7,7 +7,7 @@ import pytest from prik.parsers.fortran import parse_fortran_file as parse_fortran_source -from prik.printers import emit_module +from prik.pipeline.pyi import emit_module_stubs from prik.semantics.fortran2ir import fortran_module_to_semantic_module from tests.fortran._support.generated_contracts import ( GeneratedContractCase, @@ -66,10 +66,12 @@ def test_overload_names_its_specific_as_the_contract_declares_it(): end module powalg_mod """ - code = emit_module( + # Contract names are completed by policy, never by the printer, so the + # module is emitted through the stage that completes them first. + code = emit_module_stubs( fortran_module_to_semantic_module(parse_fortran_source(source)), normalize_public_names=True, - ) + )["powalg_mod"] assert "def qradd_rdiag(" in code assert '@overload("qradd_rdiag")' in code 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 d73ae644b..367296a8a 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 @@ -1128,6 +1128,7 @@ }, "overload_sets": [ { + "metadata": {}, "name": "do_work", "native_scope": "scope_name_reuse_combinations", "procedures": [ diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py index b5a84e5a0..9cd773a1b 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py @@ -11,14 +11,17 @@ import pytest from prik.pipeline.pyi import pyi_paths_to_semantic_modules -from prik.policy.exports import complete_python_export_policy +from prik.policy.exports import complete_python_export_policy, contract_names_by_source from prik.printers.pyi import PyiPrinter from prik.semantics.models import ( PYTHON_EXPORTS_METADATA, ProcedureOverloadSet, + SemanticArgument, + SemanticClass, SemanticFunction, SemanticModule, SemanticOrigin, + SemanticType, ) from prik.semantics.fortran2ir import fortran_file_to_semantic_modules from prik.parsers.fortran import parse_fortran_file @@ -114,8 +117,9 @@ def test_a_withheld_declaration_is_still_reachable_for_naming(generated_contract whether the module publishes it, which completed export policy settles. """ reloaded = pyi_paths_to_semantic_modules([generated_contract])[0] + complete_python_export_policy(reloaded) - assert PyiPrinter().published_names(reloaded)["cb"] == "cb" + assert contract_names_by_source(reloaded)["cb"] == "cb" def test_a_stated_name_selects_a_declaration_by_exact_spelling(tmp_path: Path): @@ -158,3 +162,29 @@ def test_a_declaration_already_projected_to_nothing_keeps_that_decision(): assert withheld.metadata[PYTHON_EXPORTS_METADATA] == [] # A declaration no stage has projected still publishes itself. assert fresh.metadata[PYTHON_EXPORTS_METADATA] == [{"namespace": (), "name": "fresh"}] + + +def test_a_withheld_class_keeps_one_contract_identity_for_its_annotations(): + """Publication does not own the spelling needed by contract references.""" + origin = SemanticOrigin(source_language="fortran", native_scope="hidden_types") + hidden = SemanticClass(name="box_t", origin=origin) + hidden.metadata[PYTHON_EXPORTS_METADATA] = [] + inspect = SemanticFunction( + name="inspect_box", + arguments=[SemanticArgument("value", SemanticType("box_t"))], + origin=origin, + ) + module = SemanticModule( + name="hidden_types", + classes=[hidden], + functions=[inspect], + exported_names=["inspect_box"], + origin=origin, + ) + + complete_python_export_policy(module) + contract = PyiPrinter(normalize_public_names=True).emit(module) + + assert "class Box_T:" in contract + assert "value: Box_T" in contract + assert '__all__ = ["inspect_box"]' in contract 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 b87b3469c..040b4a5f8 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 @@ -3,7 +3,7 @@ import pytest import prik.pipeline.pyi as pyi_pipeline from prik.parsers.fortran import parse_fortran_file as parse_fortran_source -from prik.printers.pyi import published_name +from prik.printers.pyi import contract_name_for_source from prik.printers import ( PyiPrinter, emit_module, @@ -75,6 +75,7 @@ def test_fortran_generated_contracts_reserve_colliding_public_names_by_namespace ], origin=origin, ) + complete_python_export_policy(module) code = emit_module(module, normalize_public_names=True) @@ -662,10 +663,9 @@ def test_fortran_contract_records_no_source_name_for_a_case_only_python_name(): end module consts_mod """ - code = emit_module( - fortran_module_to_semantic_module(parse_fortran_source(source)), - normalize_public_names=True, - ) + module = fortran_module_to_semantic_module(parse_fortran_source(source)) + complete_python_export_policy(module) + code = emit_module(module, normalize_public_names=True) assert "ik: Final[Int32]" in code assert "def scale_value(" in code @@ -687,10 +687,9 @@ def test_fortran_contract_records_a_source_name_python_cannot_spell(): end module naming_mod """ - code = emit_module( - fortran_module_to_semantic_module(parse_fortran_source(source)), - normalize_public_names=True, - ) + module = fortran_module_to_semantic_module(parse_fortran_source(source)) + complete_python_export_policy(module) + code = emit_module(module, normalize_public_names=True) assert 'lambda_: Annotated[Int32, SourceName("lambda")]' in code assert 'lambda__2: Annotated[Int32, SourceName("LAMBDA_")]' in code @@ -712,6 +711,7 @@ def test_non_fortran_declaration_compares_its_native_spelling_exactly(): ], origin=origin, ) + complete_python_export_policy(module) code = emit_module(module, normalize_public_names=True) @@ -733,10 +733,11 @@ def test_generated_contract_binds_a_class_whose_python_name_renames_its_type(): ], origin=origin, ) + complete_python_export_policy(module) code = emit_module(module, normalize_public_names=True) - assert '@bind("POINT_T")\nclass PointType:' in code + assert '@bind("POINT_T")\nclass Pointtype:' in code def test_generated_contract_omits_a_class_bind_for_a_case_only_python_name(): @@ -754,10 +755,11 @@ def test_generated_contract_omits_a_class_bind_for_a_case_only_python_name(): ], origin=origin, ) + complete_python_export_policy(module) code = emit_module(module, normalize_public_names=True) - assert "class point_t:" in code + assert "class Point_T:" in code assert "@bind(" not in code @@ -1018,22 +1020,22 @@ def test_generated_contract_publishes_a_module_variable_reexport(): assert stubs["state_facade"].rstrip().endswith('__all__ = ["counter", "bump"]') -def test_two_spellings_a_case_sensitive_source_keeps_apart_publish_separately(): +def test_two_spellings_a_case_sensitive_source_keep_distinct_contract_names(): """A contract records each source name as written, so neither displaces the other. - Keying what a contract published by a folded name loses one of a pair only + Keying contract spellings by a folded source name loses one of a pair only a case-sensitive source distinguishes, and an importer then binds whichever was recorded first. """ - published = {"Foo": "Foo", "foo": "foo", "SCALE": "scale"} + completed = {"Foo": "Foo", "foo": "foo", "SCALE": "scale"} - assert published_name(published, "Foo") == "Foo" - assert published_name(published, "foo") == "foo" + assert contract_name_for_source(completed, "Foo") == "Foo" + assert contract_name_for_source(completed, "foo") == "foo" # A case-insensitive source still reaches its name under any spelling. - assert published_name(published, "scale") == "scale" - assert published_name(published, "Scale") == "scale" - assert published_name(published, "missing") is None + assert contract_name_for_source(completed, "scale") == "scale" + assert contract_name_for_source(completed, "Scale") == "scale" + assert contract_name_for_source(completed, "missing") is None # `FOO` could mean either declaration, and which one a folded lookup found # would depend on the order they were recorded in, so it names neither. - assert published_name(published, "FOO") is None - assert published_name({"foo": "foo", "Foo": "Foo"}, "FOO") is None + assert contract_name_for_source(completed, "FOO") is None + assert contract_name_for_source({"foo": "foo", "Foo": "Foo"}, "FOO") is None 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 d76ee32d9..84b292a8d 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 @@ -2,6 +2,7 @@ import pytest from prik.parsers.fortran import parse_fortran_file as parse_fortran_source +from prik.policy.exports import complete_python_export_policy from prik.printers import ( PyiPrinter, emit_module, @@ -74,6 +75,7 @@ def test_fortran_generated_contracts_emit_python_name_without_binding_the_same_n ], origin=SemanticOrigin(source_language="fortran", source_kind="module"), ) + complete_python_export_policy(module) code = emit_module(module, normalize_public_names=True) diff --git a/tests/fortran/modules/semantics/test_declaration_publication.py b/tests/fortran/modules/semantics/test_declaration_publication.py index 8c3f50b18..91070cbf6 100644 --- a/tests/fortran/modules/semantics/test_declaration_publication.py +++ b/tests/fortran/modules/semantics/test_declaration_publication.py @@ -95,7 +95,6 @@ def test_a_private_prototype_and_generic_are_written_but_not_published(tmp_path: assert "def cb() -> None: ..." in contract assert "def hidden_generic(" in contract assert '__all__ = ["run"]' in contract - assert "cb" not in PyiPrinter().published_names(module) def test_two_procedures_may_name_different_interfaces_the_same_way(tmp_path: Path): From 1430faaf513b7df0aeab1efb65fdad1532b87eab Mon Sep 17 00:00:00 2001 From: said Date: Sat, 19 Sep 2026 02:16:34 +0100 Subject: [PATCH 76/96] Decide every export, and define a type whether or not it is published Export completion now records every declaration's decision, publishing nowhere included: a private declaration, one a contract leaves out of __all__, and a nested class all record []. completed_python_exports() fails on a declaration it never completed instead of publishing it under its own name, which is what bound a leaf contract's imported sibling type in the leaf. build_function_wrapper_policy() takes module_export explicitly; the type-bound-target guess is gone. The plan kept a type only where it was published, so existence and publication were one decision. One placement rule now defines each type: where it is published, under those names; beside its parent class when it is nested and unpublished; at the root, unbound, otherwise. It replaces the two copies of the export grouping in the derived-type and class planners. DerivedTypePlan carries contract_name and nested_in, and one type_definition_name() rule names the class generated code reaches, so the binding, Python surface, docstrings, and polymorphic variants read the plan instead of filtering surface.python_names. A nested class is bound on its parent (module.outer.inner). A cleanup action on a returned derived object read its family from the type's spelling, so a result written through an import alias failed planning. LifecyclePolicy now carries the transfer's derived handoff and the name-guess fallback is deleted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 14 ++ .../pyi-contracts/functions-and-classes.md | 13 ++ prik/codegen/c/binding.py | 7 +- prik/codegen/c/python_surface.py | 32 ++++- prik/codegen/docstrings.py | 16 +-- prik/planning/models.py | 23 ++++ prik/planning/planner.py | 123 ++++++++++++------ prik/policy/completion.py | 5 +- prik/policy/construction.py | 22 ++-- prik/policy/exports.py | 58 +++++---- prik/policy/models.py | 4 + .../combined_modules/box_ops.pyi | 9 +- .../end_to_end/test_multi_source_builds.py | 14 +- .../infrastructure/codegen/test_planner.py | 8 +- .../policy/test_wrapper_policy.py | 1 + .../end_to_end/test_edited_class_surfaces.py | 39 ++++++ .../test_contract_publication_round_trip.py | 7 +- 17 files changed, 290 insertions(+), 105 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71283c9da..534e62e39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ release tags add a leading `v` to the package version. ## Unreleased +- A contract publishes exactly its `__all__`. A type it left out -- such as + the sibling type a leaf contract imports for its signatures -- was still + bound in the built module under its own name, because export completion left + it undecided and wrapper policy defaulted an undecided declaration to + publishing itself. Completion now records every declaration's decision, + publishing nowhere included, and wrapper policy fails on one it never + completed. A type published nowhere still exists natively and as a Python + class, so the procedures taking and returning it work. A class written inside + another is bound on its parent (`module.outer.inner`) rather than in the + module namespace. A cleanup action on a returned derived object reads its + family from the transfer's derived handoff, so a result type written through + an import alias (`-> box` for `from .shared_types import Box as box`) no + longer fails planning. + - Contract spelling is now completed once in post-IR policy for every declaration, including withheld helpers and class members. Generated contracts, cross-module import spelling, and class-surface policy read that diff --git a/docs/user/reference/pyi-contracts/functions-and-classes.md b/docs/user/reference/pyi-contracts/functions-and-classes.md index adfbe8d40..21fa9bbd9 100644 --- a/docs/user/reference/pyi-contracts/functions-and-classes.md +++ b/docs/user/reference/pyi-contracts/functions-and-classes.md @@ -128,6 +128,19 @@ deallocation. Use an ordinary method instead when cleanup is optional, repeatable, or must report a recoverable status to Python. +## Nest a Class + +A class written inside another is reached through it, as in Python: + +```python +class grid: + class cell: + value: Int32 +``` + +The module publishes `grid`; the nested type is `grid.cell`, not a module +attribute of its own. + ## Type-Bound and Magic Methods Type-bound and magic methods follow the same rules: diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 5eb259a5c..ba4d49adb 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -331,12 +331,7 @@ def binding_module(self, plan: ModulePlan) -> CModule: self._binding_allocatable_holder_owner_paths = frozenset(plan.binding.allocatable_holder_type_owner_paths) self._binding_pointer_holder_owner_paths = frozenset(plan.binding.pointer_holder_type_owner_paths) # Stage 2: complete the immutable name index consumed by Python-surface emission. - class_python_names = { - surface.type_identity: surface.python_names[0] - for namespace in plan.namespaces - for surface in namespace.classes - if surface.python_names - } + class_python_names = {derived.type_identity: derived.definition_name for derived in self._derived_types(plan)} # Generated code fetching a wrapped type out of its namespace needs the # name that namespace published it under. That is planned once, here, # so no emission site re-derives it from the native type name. diff --git a/prik/codegen/c/python_surface.py b/prik/codegen/c/python_surface.py index 74e8c0115..f7e1476ab 100644 --- a/prik/codegen/c/python_surface.py +++ b/prik/codegen/c/python_surface.py @@ -93,8 +93,8 @@ def _class_surfaces(namespace: NamespacePlan) -> dict[tuple[str, str], ClassSurf @staticmethod def _class_names(namespace: NamespacePlan) -> dict[tuple[str, str], str]: - """Index visible class names needed for inheritance rendering.""" - return {surface.type_identity: surface.python_names[0] for surface in namespace.classes if surface.python_names} + """Index the names this namespace defines its classes under.""" + return {derived.type_identity: derived.definition_name for derived in namespace.derived_types} def _direct_ops_names(self, namespace: NamespacePlan) -> dict[tuple[str, str], str]: """Index operation dictionaries inherited by generated subclasses.""" @@ -132,7 +132,7 @@ def _derived_type_python_source( ops_names: dict[tuple[str, str], str], ) -> str: """Return one opaque wrapper assembled from its completed class surface.""" - name = derived.python_names[0] + name = derived.definition_name ops_name = self._direct_type_ops_name(derived) base = self._class_base_name(surface, class_names) base_ops = self._class_base_ops_name(surface, ops_names) @@ -148,8 +148,33 @@ def _derived_type_python_source( lines.extend(self._class_constructor_python_lines(surface)) lines.extend(self._derived_class_member_python_lines(derived, surface)) lines.extend(self._class_wrap_helper_python_lines(surface, name, ops_name)) + lines.extend(self._unbound_class_python_lines(derived, class_names)) return "\n".join(lines) + @staticmethod + def _unbound_class_python_lines( + derived: DerivedTypePlan, + class_names: dict[tuple[str, str], str], + ) -> tuple[str, ...]: + """Name a class bound under no public name, and bind it on its parent. + + Such a class is defined under a private name, so it takes the name its + contract calls it by. A nested one is then reached through its parent + and qualified by it, as a class written inside another is in Python. + """ + if derived.python_names: + return () + name = derived.definition_name + contract = derived.contract_name + if derived.nested_in is None: + return (f"{name}.__name__ = {name}.__qualname__ = {contract!r}",) + parent = class_names[derived.nested_in] + return ( + f"{name}.__name__ = {contract!r}", + f"{name}.__qualname__ = {parent}.__qualname__ + {'.' + contract!r}", + f"{parent}.{contract} = {name}", + ) + @staticmethod def _class_base_ops_name( surface: ClassSurfacePlan | None, @@ -591,6 +616,7 @@ def _module_proxy_ops_literal( native_type_name="state_t", native_scope="state", python_names=("State",), + contract_name="State", fields=(), bind_c=False, ) diff --git a/prik/codegen/docstrings.py b/prik/codegen/docstrings.py index 0608c774b..970c376dd 100644 --- a/prik/codegen/docstrings.py +++ b/prik/codegen/docstrings.py @@ -31,6 +31,7 @@ ConstructorPlan, DatatypeFamily, DerivedFieldPlan, + DerivedTypePlan, FunctionPlan, ModulePlan, ModuleVariablePlan, @@ -94,10 +95,9 @@ def render(self, plan: ModulePlan) -> ModulePlan: # way its namespace publishes it. Planning settled that name; indexing # it here keeps every rendered signature reading the same one. self._published_class_names = { - surface.type_identity[1].casefold(): surface.python_names[0] + derived.type_identity[1].casefold(): derived.contract_name for namespace in plan.namespaces - for surface in namespace.classes - if surface.python_names + for derived in namespace.derived_types } self._module_variables_by_owner = {variable.owner_path: variable for variable in plan.variables} # A publication can sort before the namespace that owns its canonical @@ -121,8 +121,7 @@ def _render_namespace(self, module_name: str, namespace: NamespacePlan) -> None: derived_types = {item.type_identity: item for item in namespace.derived_types} for surface in namespace.classes: - derived_type = derived_types.get(surface.type_identity) - self._render_class_surface(surface, () if derived_type is None else derived_type.fields) + self._render_class_surface(surface, derived_types[surface.type_identity]) if namespace.docstring is None: variable_publications = tuple( @@ -168,9 +167,10 @@ def _render_overload(self, overload: OverloadPlan) -> None: def _render_class_surface( self, surface: ClassSurfacePlan, - fields: tuple[DerivedFieldPlan, ...], + derived_type: DerivedTypePlan, ) -> None: """Render one class's dependent records before its aggregate summary.""" + fields = derived_type.fields for field in fields: self._render_field(field) for method in surface.methods: @@ -186,10 +186,10 @@ def _render_class_surface( if constructor.overload is not None: self._render_overload(constructor.overload) if constructor.docstring is None: - constructor.docstring = self.constructor(surface.python_names[0], constructor, fields) + constructor.docstring = self.constructor(derived_type.contract_name, constructor, fields) if surface.docstring is None: surface.docstring = self.class_surface( - surface.python_names[0], + derived_type.contract_name, surface.type_identity[1], constructor, fields, diff --git a/prik/planning/models.py b/prik/planning/models.py index 945ebae43..7b69a6a07 100644 --- a/prik/planning/models.py +++ b/prik/planning/models.py @@ -332,6 +332,11 @@ class DerivedTypePlan(StageRecord): The planner supplies identity, native naming, fields, and abstractness; generated class assembly uses this record as the authoritative type shape. + + A type exists whether or not it is published: a published signature may + take or return one. ``python_names`` are the names this namespace binds it + under, possibly none; ``contract_name`` is what the contract calls it; and + ``nested_in`` names the class it is bound on instead of a namespace. """ owner_path: str @@ -341,9 +346,27 @@ class DerivedTypePlan(StageRecord): native_type_name: str native_scope: str python_names: tuple[str, ...] + contract_name: str fields: tuple[DerivedFieldPlan, ...] bind_c: bool abstract: bool = False + nested_in: tuple[str, str] | None = None + + @property + def definition_name(self) -> str: + """Return the name generated code defines and reaches this type by here.""" + return type_definition_name(self.python_names, self.backend_symbol) + + +def type_definition_name(python_names: tuple[str, ...], backend_symbol: str) -> str: + """Return the name generated code defines a type under and reaches it by. + + A bound type is defined under the first name it is bound as. A type bound + under no public name is still defined -- generated code has to reach the + class to wrap a returned instance, subclass it, or check an argument -- so + it takes a private name no contract publishes. + """ + return python_names[0] if python_names else f"_prik_type_{backend_symbol}" @dataclass diff --git a/prik/planning/planner.py b/prik/planning/planner.py index 66d6ad99d..dc6fbd866 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -146,6 +146,7 @@ CharacterLocalPlan, ScalarDescriptorResultPlan, TransformationPlan, + type_definition_name, ) from prik.naming.native_symbols import NativeSymbolNames from prik.semantics.scalar_types import BOOLEAN_SEMANTIC_TYPE_NAMES @@ -236,6 +237,16 @@ def from_semantic_class(cls, semantic_class: models.SemanticClass) -> _ClassPoli ) +@dataclass(frozen=True) +class _TypePlacement: + """One namespace a type is defined in, the names binding it, and its parent.""" + + entry: _ClassPolicyEntry + namespace: tuple[str, ...] + python_names: tuple[str, ...] + nested_in: tuple[str, str] | None + + @dataclass(frozen=True) class _ClassPolicyCatalog: """Organize completed class policies for one wrapper-planning operation. @@ -367,7 +378,6 @@ def _visit_SemanticModule(self, module: models.SemanticModule) -> ModulePlan: # Initialize every class-backed index from one complete ordered collection. semantic_classes = _ClassPolicyCatalog.ordered_semantic_classes(module.classes) class_policies = _ClassPolicyCatalog.from_semantic_classes(semantic_classes) - self._derived_type_names = {semantic_class.name for semantic_class in semantic_classes} self._derived_field_plans: dict[str, DerivedFieldPlan] = {} self._complete_derived_backend_symbols(semantic_classes) @@ -503,13 +513,14 @@ def _namespace_member_plans( # Project ordinary module members independently from class-owned surfaces. functions = self._functions_by_namespace(module) variables, variable_publications = self._module_variables_and_publications(module) + placements = self._type_placements(class_policies) return ( functions, variables, variable_publications, - self._derived_types_by_namespace(class_policies), - self._classes_by_namespace(module.name, class_policies), + self._derived_types_by_namespace(placements), + self._classes_by_namespace(module.name, placements), self._module_overloads_by_namespace(module), ) @@ -680,12 +691,12 @@ def _complete_derived_backend_symbols( self._derived_backend_symbols = { policy.type_identity: self._derived_backend_symbol_for_policy(policy, counts) for policy in policies } - self._class_python_names = self._completed_class_python_names(policies) - - @staticmethod - def _completed_class_python_names(policies: tuple[DerivedTypePolicy, ...]) -> dict[tuple[str, str], str]: - """Index the primary completed Python export for each native type.""" - return {policy.type_identity: policy.python_names[0] for policy in policies if policy.python_names} + self._class_definition_names = { + policy.type_identity: type_definition_name( + policy.python_names, self._derived_backend_symbols[policy.type_identity] + ) + for policy in policies + } @staticmethod def _derived_backend_symbol_for_policy(policy: DerivedTypePolicy, counts: Counter) -> str: @@ -709,32 +720,65 @@ def _derived_backend_symbol(self, type_identity: tuple[str, str]) -> str: # Derived-type definitions, fields, and class surfaces. def _derived_types_by_namespace( self, - class_policies: _ClassPolicyCatalog, + placements: tuple[_TypePlacement, ...], ) -> dict[tuple[str, ...], list[DerivedTypePlan]]: """Project opaque types from completed class and field policies.""" grouped = defaultdict(list) - for entry in class_policies.entries: - policy = entry.derived_policy - surface = entry.surface_policy - exports_by_namespace = defaultdict(list) - for export in policy.python_exports: - exports_by_namespace[export.namespace].append(export.name) - for namespace, python_names in exports_by_namespace.items(): - grouped[namespace].append( - self._derived_type_plan( - policy, - tuple(python_names), - fields=surface.effective_fields, - ) + for placement in placements: + entry = placement.entry + grouped[placement.namespace].append( + self._derived_type_plan( + entry.derived_policy, + placement.python_names, + fields=entry.surface_policy.effective_fields, + contract_name=models.completed_contract_name(entry.semantic_class), + nested_in=placement.nested_in, ) + ) return grouped + @staticmethod + def _type_placements(class_policies: _ClassPolicyCatalog) -> tuple[_TypePlacement, ...]: + """Return each namespace a type is defined in, and the names bound there. + + A type is defined in every namespace that publishes it, under the names + it is published as. A type that publishes nowhere still exists -- a + published signature may take or return one -- so it is defined once + without a public name: beside its parent class, which binds it, when it + is nested, and at the root otherwise. + """ + parents = {id(child): entry for entry in class_policies.entries for child in entry.semantic_class.classes} + homes: dict[int, tuple[tuple[str, ...], ...]] = {} + placements: list[_TypePlacement] = [] + # Entries arrive parents first, so a nested type finds its parent's home. + for entry in class_policies.entries: + published: dict[tuple[str, ...], list[str]] = defaultdict(list) + for export in entry.derived_policy.python_exports: + published[export.namespace].append(export.name) + parent = parents.get(id(entry.semantic_class)) + if published: + found = tuple( + _TypePlacement(entry, namespace, tuple(names), None) for namespace, names in published.items() + ) + elif parent is not None: + found = tuple( + _TypePlacement(entry, namespace, (), parent.derived_policy.type_identity) + for namespace in homes[id(parent.semantic_class)] + ) + else: + found = (_TypePlacement(entry, (), (), None),) + homes[id(entry.semantic_class)] = tuple(item.namespace for item in found) + placements.extend(found) + return tuple(placements) + def _derived_type_plan( self, policy: DerivedTypePolicy, python_names: tuple[str, ...], *, fields: tuple[DerivedFieldPolicy, ...] | None = None, + contract_name: str, + nested_in: tuple[str, str] | None, ) -> DerivedTypePlan: """Mechanically project one completed derived type and its public fields.""" planned_fields = tuple(self._derived_field_plan(field) for field in (fields or policy.fields)) @@ -749,30 +793,27 @@ def _derived_type_plan( fields=planned_fields, bind_c=policy.bind_c, abstract=policy.abstract, + contract_name=contract_name, + nested_in=nested_in, ) # Generated class surfaces compose Phase 8 types and ordinary function plans. def _classes_by_namespace( self, module_name: str, - class_policies: _ClassPolicyCatalog, + placements: tuple[_TypePlacement, ...], ) -> dict[tuple[str, ...], list[ClassSurfacePlan]]: - """Project completed class surfaces into their public namespaces.""" + """Project each class surface beside the type it is defined with.""" grouped = defaultdict(list) - for entry in class_policies.entries: - policy = entry.surface_policy - exports_by_namespace = defaultdict(list) - for export in policy.python_exports: - exports_by_namespace[export.namespace].append(export.name) - for namespace, python_names in exports_by_namespace.items(): - grouped[namespace].append( - self._class_surface_plan( - module_name, - namespace, - entry, - tuple(python_names), - ) + for placement in placements: + grouped[placement.namespace].append( + self._class_surface_plan( + module_name, + placement.namespace, + placement.entry, + placement.python_names, ) + ) return grouped def _class_surface_plan( @@ -1977,7 +2018,7 @@ def _polymorphic_dispatch_plan( PolymorphicVariantPlan( type_identity=identity, backend_symbol=self._derived_backend_symbol(identity), - python_name=self._class_python_names[identity], + python_name=self._class_definition_names[identity], abi_code=index, ) for index, identity in enumerate(policy.variants, start=1) @@ -2096,7 +2137,7 @@ def _visit_LifecyclePolicy( policy: LifecyclePolicy, ) -> LifecycleActionPlan: """Return one transfer-owned action for function-wide ordering.""" - family = self._datatype_family(policy.semantic_type_name) + family = self._transfer_datatype_family(policy.semantic_type_name, policy.derived) binding = None bridge = None if policy.phase is WritebackPhase.NATIVE_MUTATION: @@ -2820,8 +2861,6 @@ def _datatype_family(self, semantic_type_name: str) -> DatatypeFamily: try: return _DATATYPE_FAMILIES[semantic_type_name] except KeyError: - if semantic_type_name in getattr(self, "_derived_type_names", set()): - return DatatypeFamily.DERIVED raise ValueError(f"Unsupported first-lane scalar type {semantic_type_name!r}") from None def _transfer_datatype_family( diff --git a/prik/policy/completion.py b/prik/policy/completion.py index cf1e43209..417d29111 100644 --- a/prik/policy/completion.py +++ b/prik/policy/completion.py @@ -369,6 +369,7 @@ def _complete_ownership_policies( procedure, f"{procedure_scope}.{overload_set.name}.{procedure.name}", derived_types=derived_types, + module_export=False, ) # Build resolved module overload tables after every candidate is complete. overload_functions = { @@ -718,6 +719,7 @@ def _complete_concrete_class_methods( method, function_owner_path, derived_types=derived_types, + module_export=False, class_call=calls.get(owner_path), polymorphic_variants=polymorphic_variants, ) @@ -805,6 +807,7 @@ def _complete_one_class_overload_method( owner_path, derived_types=derived_types, class_call=call, + module_export=False, polymorphic_variants=polymorphic_variants, native_dispatch_name=native_dispatch_name, ) @@ -1097,7 +1100,7 @@ def _complete_function( *, derived_types: dict[tuple[str, str], DerivedTypePolicy] | None = None, class_call: ClassMethodPolicy | None = None, - module_export: bool | None = None, + module_export: bool, polymorphic_variants: dict[tuple[str, str], tuple[tuple[str, str], ...]] | None = None, native_dispatch_name: str | None = None, ) -> None: diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 7e002e886..6b17b1b5b 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -441,7 +441,7 @@ def build_derived_type_policy( else [] ) ) - exports = completed_python_exports(semantic_class, semantic_class.name) + exports = completed_python_exports(semantic_class) native_type_name = str(semantic_class.native_name or semantic_class.name) native_scope = str(semantic_class.origin.native_scope or owner_path.split(".", 1)[0]) return DerivedTypePolicy( @@ -790,7 +790,7 @@ def build_module_overload_policy( return _overload_policy( native_scope, overload, - python_exports=completed_python_exports(first, overload.name), + python_exports=completed_python_exports(first), module_generic=True, ) @@ -1068,7 +1068,7 @@ def _module_variable_policy_base( return { "owner_path": owner_path, "name": variable.name, - "python_exports": completed_python_exports(variable, variable.name), + "python_exports": completed_python_exports(variable), "native_name": str(variable.origin.native_name or variable.name), "native_module": str(variable.origin.native_scope or module_name), "semantic_type_name": variable.semantic_type.name, @@ -1703,7 +1703,7 @@ def build_function_wrapper_policy( owner_path: str, derived_types: Mapping[tuple[str, str], DerivedTypePolicy] | None = None, class_call: ClassMethodPolicy | None = None, - module_export: bool | None = None, + module_export: bool, polymorphic_variants: Mapping[tuple[str, str], tuple[tuple[str, str], ...]] | None = None, native_dispatch_name: str | None = None, ) -> FunctionWrapperPolicy: @@ -1802,7 +1802,10 @@ def build_function_wrapper_policy( blockers = (*blockers, *entrypoint_diagnostics) return FunctionWrapperPolicy( owner_path=owner_path, - python_exports=completed_python_exports(function, function.name), + # Only a module-level publication has module exports: a method is + # reached through its class and an overload candidate through its + # generic, whose own policies carry their placement. + python_exports=completed_python_exports(function) if module_export else (), native_name=native_name, native_invocation=native_invocation, native_operator=native_operator, @@ -1819,9 +1822,7 @@ def build_function_wrapper_policy( release_gil=bool(function.metadata.get(models.RUNTIME_RELEASE_GIL_METADATA)), status_error=status_error, class_call=class_call, - module_export=( - not bool(function.metadata.get("fortran_type_bound_target")) if module_export is None else module_export - ), + module_export=module_export, supported=not blockers, arguments=tuple(arguments), results=results, @@ -6101,6 +6102,7 @@ def _lifecycle_policies( semantic_type_name=argument.semantic_type_name, result_position=argument.result_position, object_kind=argument.ownership.kind, + derived=argument.derived, ) for phase in phases ) @@ -6126,6 +6128,7 @@ def action(result: ResultPolicy, operation: LifecycleOperation) -> LifecyclePoli semantic_type_name=result.semantic_type_name, result_position=result.result_position, object_kind=result.ownership.kind, + derived=result.derived, operation=operation, ) @@ -8242,8 +8245,9 @@ def _argument_native_name( python_barrier_action=PythonBarrierAction.NONE, native_barrier_action=NativeBarrierAction.NONE, ) + semantic_function.metadata[models.PYTHON_EXPORTS_METADATA] = [{"namespace": (), "name": "scale"}] print(f"before: math.scale({semantic_argument.name}): {semantic_argument.semantic_type.name} semantic IR") - policy = build_function_wrapper_policy(semantic_function, owner_path="math.scale") + policy = build_function_wrapper_policy(semantic_function, owner_path="math.scale", module_export=True) print( f"after: {policy.arguments[0].bridge_data_action.value}; " f"result={policy.results[0].direct_result_abi.value}; " diff --git a/prik/policy/exports.py b/prik/policy/exports.py index 09327e6d7..c12e9fad6 100644 --- a/prik/policy/exports.py +++ b/prik/policy/exports.py @@ -73,17 +73,19 @@ def complete_python_export_policy( preserve_case=contract_named or preserves_source_case(module.origin.source_language), ) for owner in _module_export_owners(module): - if getattr(owner, "visibility", "public") == "private": - continue - if stated is not None and str(owner.name) not in stated: - continue metadata = _owner_metadata(owner) + if getattr(owner, "visibility", "public") == "private" or ( + stated is not None and str(owner.name) not in stated + ): + # Completion states every owner's decision, publishing nowhere + # included, so no later reader is left to answer it differently. + metadata.setdefault(models.PYTHON_EXPORTS_METADATA, []) + continue exports = metadata.get(models.PYTHON_EXPORTS_METADATA) if exports is None: - # No stage has projected this declaration yet, so it publishes - # itself in its own namespace. An empty list is not that: it is a - # stage having decided the declaration publishes nothing, and - # replacing it here would reverse that decision. + # No earlier stage placed this declaration, so it publishes itself + # in its own namespace. An empty list is not that: it is a stage + # having decided the declaration publishes nothing. exports = [{"namespace": (), "name": None}] metadata[models.PYTHON_EXPORTS_METADATA] = exports category = _owner_category(owner) @@ -97,6 +99,10 @@ def complete_python_export_policy( owner=f"{category} {owner.name}", ) export["name"] = resolved_name + # A nested class is bound on its parent class, never in a namespace. + for parent in _all_classes(module.classes): + for nested in parent.classes: + nested.metadata.setdefault(models.PYTHON_EXPORTS_METADATA, []) _complete_reexport_names(module, naming, contract_named=contract_named) _complete_contract_names( module, @@ -507,13 +513,21 @@ def _owner_category(owner) -> str: return "function" -def completed_python_exports( - owner: models.SemanticFunction | models.SemanticVariable, - default_name: str, -) -> tuple[PythonExportPolicy, ...]: - """Return stable local names grouped by their completed namespace path.""" +def completed_python_exports(owner) -> tuple[PythonExportPolicy, ...]: + """Return the placements completion recorded for one declaration. + + This reads the decision and never makes it. Completion records one for + every declaration it reaches, publishing nowhere included, so an empty + result is an answer; a missing one means completion never ran. + """ + recorded = owner.metadata.get(models.PYTHON_EXPORTS_METADATA) + if recorded is None: + raise ValueError( + f"Python export policy for {owner.name!r} is incomplete; " + "run complete_semantic_policies before wrapper planning" + ) exports = [] - for item in owner.metadata.get(models.PYTHON_EXPORTS_METADATA, ()): + for item in recorded: if not isinstance(item, dict): continue name = item.get("name") @@ -522,19 +536,7 @@ def completed_python_exports( f"Python export policy for {owner.name!r} is incomplete; " "run complete_semantic_policies before wrapper planning" ) - exports.append( - PythonExportPolicy( - namespace=export_namespace(item), - name=str(name), - ) - ) - if not exports and getattr(owner, "visibility", "public") != "private": - fallback = normalize_public_name( - default_name, - preserve_case=preserves_source_case(owner.origin.source_language), - category=_owner_category(owner), - ) - exports.append(PythonExportPolicy((), fallback.name)) + exports.append(PythonExportPolicy(namespace=export_namespace(item), name=str(name))) return tuple(dict.fromkeys(exports)) @@ -550,7 +552,7 @@ def completed_python_exports( ) example_module = models.SemanticModule("math", functions=[example_function]) complete_python_export_policy(example_module) - example_export = completed_python_exports(example_function, example_function.name)[0] + example_export = completed_python_exports(example_function)[0] print(f"Native semantic owner: {example_module.name}.{example_function.native_name}") print(f"Python export: {'.'.join((*example_export.namespace, example_export.name))}") diff --git a/prik/policy/models.py b/prik/policy/models.py index b1c93bff2..f712cbeb9 100644 --- a/prik/policy/models.py +++ b/prik/policy/models.py @@ -1007,6 +1007,9 @@ class LifecyclePolicy: semantic_type_name: str result_position: int object_kind: ObjectKind + # The handoff of the transfer this action belongs to, which is what makes + # the value a derived object; its type's spelling does not. + derived: DerivedHandoffPolicy | None operation: LifecycleOperation = LifecycleOperation.WRITEBACK @@ -1500,6 +1503,7 @@ class FunctionWrapperPolicy: semantic_type_name="Float64", result_position=0, object_kind=ObjectKind.NUMPY_ARRAY, + derived=None, ) print(f"Array policy: rank={example_array.rank}, shape={example_array.shape}, order={example_array.order}") 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 9dcbf8d69..adc70844b 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,8 +1,13 @@ -from prik.contracts import Int32 +from prik.contracts import Addr, Arg, Int32, native_call from .shared_types import Box as box def box_value( item: box ) -> Int32: ... -__all__ = ["box_value"] +@native_call([Addr(Arg(0))]) +def boxed( + value: Int32 +) -> box: ... + +__all__ = ["box_value", "boxed"] 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 574f6af8d..a11d272cf 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 @@ -64,6 +64,11 @@ type(box), intent(in) :: item out = item%value end function box_value +function boxed(value) result(out) + integer, intent(in) :: value + type(box) :: out + out%value = value +end function boxed end module box_ops """ @@ -358,8 +363,13 @@ def test_generated_module_leaf_loads_sibling_type_contract(tmp_path: Path): str(entry.parent / "box_ops.pyi"), str(entry.parent / "shared_types.pyi"), ] - box = module.Box() - box.value = np.int32(7) + # The leaf publishes what its `__all__` states. The sibling type it + # imports for its signatures is bound under no name of its own, yet it is + # a real class its procedures return and accept. + assert not hasattr(module, "Box") + assert not hasattr(module, "box") + box = module.boxed(np.int32(7)) + assert type(box).__name__ == "Box" assert module.box_value(box) == np.int32(7) diff --git a/tests/fortran/infrastructure/codegen/test_planner.py b/tests/fortran/infrastructure/codegen/test_planner.py index 7568ef3e2..e03fb84d9 100644 --- a/tests/fortran/infrastructure/codegen/test_planner.py +++ b/tests/fortran/infrastructure/codegen/test_planner.py @@ -245,7 +245,13 @@ def move(self, dx: Float64) -> None: ... plan = WrapperPlanner().build(module) generated = WrapperGenerator().generate(plan) - assert tuple(derived.type_name for derived in plan.namespaces[0].derived_types) == ("outer", "inner") + planned_outer, planned_inner = plan.namespaces[0].derived_types + assert (planned_outer.type_name, planned_inner.type_name) == ("outer", "inner") + # The nested type is defined beside its parent and bound on it, not here. + assert planned_outer.python_names == ("outer",) + assert planned_inner.python_names == () + assert planned_inner.nested_in == planned_outer.type_identity + assert planned_inner.contract_name == "inner" assert {source.path.suffix for source in generated.sources} == {".c", ".h", ".f90"} diff --git a/tests/fortran/infrastructure/policy/test_wrapper_policy.py b/tests/fortran/infrastructure/policy/test_wrapper_policy.py index c4781ed10..7216d7213 100644 --- a/tests/fortran/infrastructure/policy/test_wrapper_policy.py +++ b/tests/fortran/infrastructure/policy/test_wrapper_policy.py @@ -122,6 +122,7 @@ def hidden_status() -> Int32: ... policy = build_function_wrapper_policy( function, owner_path="missing_hidden_projection.hidden_status", + module_export=True, ) assert policy.results == () diff --git a/tests/fortran/infrastructure/semantic_pyi/contracts/functions_and_classes/end_to_end/test_edited_class_surfaces.py b/tests/fortran/infrastructure/semantic_pyi/contracts/functions_and_classes/end_to_end/test_edited_class_surfaces.py index 14172901b..cdec34766 100644 --- a/tests/fortran/infrastructure/semantic_pyi/contracts/functions_and_classes/end_to_end/test_edited_class_surfaces.py +++ b/tests/fortran/infrastructure/semantic_pyi/contracts/functions_and_classes/end_to_end/test_edited_class_surfaces.py @@ -6,6 +6,7 @@ import pytest from tests.fortran._support.wrapper_build import ( + _build_inline_pyi_contract_module, _compile_native_object, _import_from_build_dir, _sole_native_module, @@ -127,3 +128,41 @@ def test_private_native_specific_without_overload_bind_fails_at_build( for target in missing_targets: assert target in error assert "not found in module" in error + + +def test_nested_class_is_bound_on_its_parent_not_the_namespace(tmp_path: Path): + module, _ = _build_inline_pyi_contract_module( + tmp_path, + module_name="nesting", + source_text="""\ +module nesting + type :: outer + integer :: value + end type outer + type :: inner + integer :: value + end type inner +end module nesting +""", + contract_text="""\ +from prik.contracts import Int32 + +class outer: + value: Int32 + + class inner: + def __init__( + self, + *, + value: Int32 = ... + ) -> None: ... + + value: Int32 +""", + ) + + assert not hasattr(module, "inner") + assert module.outer.inner.__qualname__ == "outer.inner" + item = module.outer.inner(value=np.int32(3)) + assert type(item) is module.outer.inner + assert item.value == np.int32(3) diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py index 9cd773a1b..b37d0a832 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py @@ -97,8 +97,9 @@ def test_reloading_a_contract_does_not_publish_what_all_leaves_out(generated_con for owner in (*reloaded.functions, *reloaded.overload_sets) } assert published["run"] == [{"namespace": (), "name": "run"}] - assert published["hidden_generic"] is None - assert published["hidden_one"] is None + # Completion records the decision to publish nowhere, not an absence. + assert published["hidden_generic"] == [] + assert published["hidden_one"] == [] def test_a_contract_read_back_and_written_again_states_the_same_surface(generated_contract: Path): @@ -138,7 +139,7 @@ def test_a_stated_name_selects_a_declaration_by_exact_spelling(tmp_path: Path): complete_python_export_policy(module) assert [item.name for item in module.functions] == ["foo"] - assert module.functions[0].metadata.get(PYTHON_EXPORTS_METADATA) is None + assert module.functions[0].metadata.get(PYTHON_EXPORTS_METADATA) == [] def test_a_declaration_already_projected_to_nothing_keeps_that_decision(): From 50e1ed06bb166dd77983fd2add7575f841b1e836 Mon Sep 17 00:00:00 2001 From: said Date: Sat, 19 Sep 2026 03:47:47 +0100 Subject: [PATCH 77/96] Plan a contract's imports once, from what it needs to bind A generated contract copied its module's `use` statements and then the printer patched the list with five synthesizers, each deduplicating its own way, and spelled every item through three name maps and a normalization fallback. A facade extending a generic it `use`s from two modules therefore imported `convert` from each of them beside declaring the merged generic, and the package it generated could not be read back. complete_contract_imports() now completes each module's imports once, after names, over the modules written together. A contract binds the names its declarations use and the names it publishes, and every binding passes one table keyed by local name: a module never imports from itself or a declaration it carries, one entity reached twice binds once, and a name meaning two entities is refused. Each item records both spellings, and the printer renders them without choosing or spelling anything; its synthesizers, _public_import_name, _verbatim_import_names, the namespace validators, and the declared_prototype_names/contract_names_by_module plumbing are deleted. fortran2ir no longer mirrors `use` statements. It records the use associations the module's declarations depend on, each read from the module declaring the entity, sharing one resolution pass with re-export records. A re-export from a compiler-supplied module records the kind `intrinsic`, which publication policy treats as unpublishable, so neither the printer nor the planner keeps its own intrinsic-module check. A renamed import written with capitals now binds the name its annotation writes; it bound the normalized published spelling before. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 14 + docs/developer/codebase-map.md | 2 +- docs/developer/packages/policy.md | 10 + docs/developer/packages/printers.md | 4 + prik/pipeline/pyi.py | 36 +- prik/policy/contract_imports.py | 233 ++++++++ prik/policy/exports.py | 5 +- prik/printers/pyi.py | 541 ++---------------- prik/semantics/fortran2ir.py | 119 ++-- prik/semantics/models.py | 13 +- tests/fortran/_support/printer_models.py | 2 + .../arrays/semantics/test_array_semantics.py | 4 + .../test_multi_file_contract_generation.py | 5 +- .../semantics/test_types_and_storage.py | 4 +- .../end_to_end/test_merged_generic_replay.py | 50 ++ .../test_generic_contributor_merging.py | 26 + .../general/expected/module_vars_use.json | 20 +- .../test_pyi_printer_imports_and_packages.py | 78 ++- .../semantics/test_round_trip_properties.py | 4 + .../semantics/test_declaration_publication.py | 2 + .../semantics/test_modules_and_imports.py | 4 +- 21 files changed, 539 insertions(+), 637 deletions(-) create mode 100644 prik/policy/contract_imports.py create mode 100644 tests/fortran/generic_interfaces/end_to_end/test_merged_generic_replay.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 534e62e39..7fa99ffa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ release tags add a leading `v` to the package version. ## Unreleased +- A generated contract imports what it needs to bind, not the `use` + statements its source wrote. A facade extending a generic it `use`s from two + modules imported `convert` from each of them beside declaring the merged + generic itself, so the package it generated could not be read back and only + the source build worked. Imports are now completed once, before emission, by + `complete_contract_imports()`: a contract binds the names its declarations use + and the names it publishes, each from the module declaring it, and a name + reached twice through re-exporting modules binds once. The `.pyi` printer + renders those statements and no longer chooses or spells an import. A renamed + import written with capitals (`use shapes, only : MyPoint => point`) now binds + the name its annotations use. A `use` whose names no declaration mentions and + the module does not publish, such as a bare `use types_mod`, is no longer + written into the contract. + - A contract publishes exactly its `__all__`. A type it left out -- such as the sibling type a leaf contract imports for its signatures -- was still bound in the built module under its own name, because export completion left diff --git a/docs/developer/codebase-map.md b/docs/developer/codebase-map.md index e5f5fd4d2..c9662faff 100644 --- a/docs/developer/codebase-map.md +++ b/docs/developer/codebase-map.md @@ -39,7 +39,7 @@ boundary; the modules are where the change lands. | Prepared source, provenance, and target facts | [`prik.preprocessing`](packages/preprocessing.md) | `source.py`, `fortran.py`, `c.py`, `probes/fortran_types.py`, `probes/c_types.py` | | Parsed language facts | [`prik.parsers`](packages/parsers.md) | `fortran/parser.py`, `pyi/parser.py`, `c/` | | Shared language-neutral meaning | [`prik.semantics`](packages/semantics.md) | `models.py`, `fortran2ir.py`, `pyi2ir.py`, `c2ir.py`, `scalar_types.py` | -| Completed interoperability policy | [`prik.policy`](packages/policy.md) | `completion.py`, `construction.py`, `ownership.py`, `exports.py`, `native_array_handles.py` | +| Completed interoperability policy | [`prik.policy`](packages/policy.md) | `completion.py`, `construction.py`, `ownership.py`, `exports.py`, `contract_imports.py`, `native_array_handles.py` | | Deterministic wrapper planning | [`prik.planning`](packages/planning.md) | `models.py`, `planner.py`, `entrypoints.py` | | Binding, bridge, and Python-facade lowering | [`prik.codegen`](packages/codegen.md) | `c/binding.py`, `c/python_surface.py`, `fortran/bridge.py`, `primitive_scalar_types.py` | | Generated-text serialization | [`prik.printers`](packages/printers.md) | `c.py`, `fortran.py`, `pyi.py` | diff --git a/docs/developer/packages/policy.md b/docs/developer/packages/policy.md index 2e2035383..85e63cd9c 100644 --- a/docs/developer/packages/policy.md +++ b/docs/developer/packages/policy.md @@ -30,6 +30,7 @@ prik/policy/ ├── models.py ├── ownership.py ├── exports.py +├── contract_imports.py ├── construction.py ├── completion.py └── native_array_handles.py @@ -58,6 +59,7 @@ downstream fallback. | [`prik/policy/models.py`](../../../prik/policy/models.py) | Immutable records and enums for function, argument, result, slot, lifecycle, class, overload, callback, array, descriptor, status, and transformation policy. | A completed decision needs a durable backend-neutral representation. | | [`prik/policy/ownership.py`](../../../prik/policy/ownership.py) | Ownership vocabulary, `OwnershipContext`, `OwnershipDecision`, `OwnershipPolicyResolver`, and action dispatchers resolve lifetime triples and fail-closed lowering actions. | Object kind, owner, transfer, destruction, storage, barrier, assignment, or setter selection changes. | | [`prik/policy/exports.py`](../../../prik/policy/exports.py) | `complete_python_export_policy()` completes collision-checked contract spellings and Python placement; focused readers expose those recorded decisions. | Contract naming, export namespace, visibility, or collision behavior changes. | +| [`prik/policy/contract_imports.py`](../../../prik/policy/contract_imports.py) | `complete_contract_imports()` replaces each module's imports with the names its contract binds from other modules, spelled as the sources and the completed contracts write them. | What a generated contract imports, or how an imported name is spelled, changes. | | [`prik/policy/construction.py`](../../../prik/policy/construction.py) | Feature constructors build coherent function, result, native-slot, callback, class, overload, and module-variable policies from completed ownership decisions. | A supported feature needs different completed policy composition. | | [`prik/policy/completion.py`](../../../prik/policy/completion.py) | `complete_semantic_policies()` runs the dependency-ordered completion pass, attaches outcomes, and validates blockers. | Completion order, cross-declaration completion, or the stage boundary changes. | | [`prik/policy/native_array_handles.py`](../../../prik/policy/native_array_handles.py) | `NativeArrayHandlePolicy`, ABI selectors and dispatchers, and `native_array_handle_build_requirements()` describe already-completed descriptor handles and their build requirements. | Descriptor-backed array ABI selection, allowed operations, dispatch, or build headers change. | @@ -168,6 +170,14 @@ spelling is read with `completed_contract_name()`, which lives beside read the decision without importing policy; class-surface construction reads it the same way. +`complete_contract_imports()` runs once names are complete, over the modules +written together. A contract binds what its declarations name and what it +publishes, never a `use` statement as written: a `use` that only extends a +generic the module declares binds nothing. Every binding passes one table keyed +by local name, so an entity reached twice binds once and a name meaning two +entities is refused. It replaces `SemanticModule.imports` with the result, and +the printer renders those statements without deciding any of them. + `completion.py` creates native-array handle policies for descriptor-backed arrays. `native_array_handles.py` carries those records through the rest of the build: `array_interop_policy()` selects ordinary data-buffer or descriptor diff --git a/docs/developer/packages/printers.md b/docs/developer/packages/printers.md index 3bb26d6bb..3b79c1c45 100644 --- a/docs/developer/packages/printers.md +++ b/docs/developer/packages/printers.md @@ -81,6 +81,10 @@ context records contract imports, aliases, source array defaults, and nested namespaces without mutating a reusable printer or the semantic IR. Contract spellings and overload-target spellings must already be completed on semantic owners by post-IR policy; the printer reads them and keeps no naming allocator. +Imports from other modules are the statements `complete_contract_imports()` +recorded in `SemanticModule.imports`, each item spelled both ways; the printer +writes the source spellings or the completed ones and never chooses which names +to bind. For a module, the printer first renders public classes, prototypes, variables, functions, and overload sets into body sections. As visitors use contract diff --git a/prik/pipeline/pyi.py b/prik/pipeline/pyi.py index de4904708..030c89320 100644 --- a/prik/pipeline/pyi.py +++ b/prik/pipeline/pyi.py @@ -16,7 +16,8 @@ from prik.parsers.pyi import parse_pyi_text from prik.policy.completion import complete_semantic_policies -from prik.policy.exports import complete_python_export_policy, contract_names_by_source +from prik.policy.contract_imports import complete_contract_imports +from prik.policy.exports import complete_python_export_policy from prik.printers.pyi import emit_module from prik.semantics.models import EXTERNAL_TYPE_REF_METADATA, SemanticClass, SemanticModule, _module_semantic_types from prik.semantics.pyi_metadata import PYI_LOADED_METADATA @@ -145,33 +146,14 @@ def emit_module_stubs( for module in naming_modules.values(): complete_python_export_policy(module) 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. - # 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 = { - (module_name, str(prototype.name)) - for module_name, module in naming_modules.items() - for prototype in module.prototypes - } | { - (module_name, str(reexport.local_name)) - for module_name, module in naming_modules.items() - for reexport in module.reexports - if reexport.entity_kind == "prototype" - } - # Import emission reads the spelling post-IR policy completed for every - # declaration, including withheld helpers another contract may reference. - contract_names_by_module = { - module_name: contract_names_by_source(module) for module_name, module in naming_modules.items() - } + # Each import asks the module it reads from for the name that module's + # contract declares, emitted here or not. + complete_contract_imports( + emitted_modules.values(), + dependencies=(module for name, module in naming_modules.items() if name not in emitted_modules), + ) return { - module_name: emit_module( - module, - normalize_public_names=normalize_public_names, - declared_prototype_names=declared_prototype_names, - contract_names_by_module=contract_names_by_module, - ).strip() + module_name: emit_module(module, normalize_public_names=normalize_public_names).strip() for module_name, module in emitted_modules.items() } diff --git a/prik/policy/contract_imports.py b/prik/policy/contract_imports.py new file mode 100644 index 000000000..8683260b5 --- /dev/null +++ b/prik/policy/contract_imports.py @@ -0,0 +1,233 @@ +"""Complete the names each generated contract binds from other modules. + +A contract has to bind every name it writes without declaring: a type or a +prototype its signatures name, a callable its declaration expressions call, the +module a procedure-local type is qualified by, and each name it publishes out of +another module. That, and not the ``use`` statements its source happened to +write, is what it imports. A ``use`` that only extends a generic the module +declares, or reaches a name no declaration mentions, binds nothing here. + +``complete_contract_imports`` replaces a module's ``imports`` with those +bindings, spelled both ways a contract is written: as the sources name each +side, and as the completed contracts do. The printer renders them and decides +nothing. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Iterator + +from prik.naming import normalize_public_name, preserves_source_case +from prik.policy.exports import contract_names_by_source +from prik.semantics import models +from prik.semantics.pyi_metadata import PYI_LOADED_METADATA + + +def complete_contract_imports( + modules: Iterable[models.SemanticModule], + *, + dependencies: Iterable[models.SemanticModule] = (), +) -> None: + """Replace each module's imports with the bindings its contract writes. + + Names must already be completed for ``modules`` and ``dependencies``: an + import asks the module it reads from for the name that module's contract + declares. ``dependencies`` are contracts completed alongside but not + written here. + """ + modules = list(modules) + completed = {module.name.casefold(): contract_names_by_source(module) for module in (*dependencies, *modules)} + for module in modules: + module.imports = _ContractImports(module, completed).bindings() + + +def contract_name_for_source(completed: dict[str, str] | None, source: object) -> str | None: + """Return the completed contract spelling for one source name. + + A contract records each name exactly as its source spells it, so two + declarations a case-sensitive language keeps apart keep separate entries. + A case-insensitive source may ask under any spelling, which is answered + only when one entry can mean it: where several fold together the request + names no single declaration, and guessing one would depend on the order + they happened to be recorded in. + """ + if not completed: + return None + wanted = str(source) + exact = completed.get(wanted) + if exact is not None: + return exact + folded = wanted.casefold() + matches = [value for key, value in completed.items() if key.casefold() == folded] + return matches[0] if len(matches) == 1 else None + + +class _ContractImports: + """Bind each name one contract needs, once, from the one entity it names. + + Every binding passes through ``_bind``. A module does not import from + itself or import a declaration it already carries; a name reached twice + from the same entity binds once; and a name bound to two entities, or to + one while the contract declares another, cannot be written. + """ + + def __init__(self, module: models.SemanticModule, completed: dict[str, dict[str, str]]): + self._module = module + self._completed = completed + # A loaded contract already writes Python; only a native one is spelled. + self._native = not module.metadata.get(PYI_LOADED_METADATA) + self._preserve_case = not self._native or preserves_source_case(module.origin.source_language) + self._key = str if self._preserve_case else str.casefold + declarations = (*module.functions, *module.classes, *module.variables, *module.prototypes) + self._declared_names = {self._key(str(item.name)) for item in (*declarations, *module.overload_sets)} + self._declared = { + *( + _identity(item.origin.native_scope or module.name, getattr(item, "native_name", None) or item.name) + for item in declarations + ), + *(_identity(item.native_scope or module.name, item.name) for item in module.overload_sets), + } + self._bound: dict[str, tuple[str, str]] = {} + self._statements: list[str | models.SemanticImport] = [] + self._from: dict[str, models.SemanticImport] = {} + + def bindings(self) -> list[str | models.SemanticImport]: + """Return the import statements the contract writes, in writing order. + + The contract's own imports come first, then the names it publishes in + the order its source reached them, then every name its declarations + refer to. Those are sorted, so reordering declarations never reorders + the imports they need. + """ + for statement in self._module.imports: + self._stated(statement) + for reexport in self._module.reexports: + # A name published out of another module is bound under the name + # the contract publishes it by; a prototype keeps its spelling. + if reexport.publishes_to_python() and reexport.origin_module: + prototype = reexport.entity_kind == "prototype" + self._bind( + str(reexport.origin_module), + str(reexport.source_name or reexport.local_name), + str(reexport.local_name), + verbatim=prototype, + published_as=None if prototype else str(reexport.python_name), + ) + for origin, source, local, kind in sorted(set(self._references())): + self._bind(origin, source, local, verbatim=kind in {"prototype", "namespace"}) + return self._statements + + def _stated(self, statement: str | models.SemanticImport) -> None: + """Carry one import the module states itself.""" + if isinstance(statement, str) or not statement.items: + self._statements.append(statement) + return + for item in statement.items: + self._bind(statement.module, item.source, item.target or item.source) + + def _references(self) -> Iterator[tuple[str, str, str, str]]: + """Yield ``(module, source, local, kind)`` for each name a declaration names.""" + for semantic_type in models._module_semantic_types(self._module): + yield from _type_reference(semantic_type.metadata.get(models.EXTERNAL_TYPE_REF_METADATA)) + yield from _prototype_reference(semantic_type.metadata.get(models.PROTOTYPE_REF_METADATA)) + yield from _callable_references(semantic_type) + + def _bind( + self, + origin: str, + source: str, + local: str, + *, + verbatim: bool = False, + published_as: str | None = None, + ) -> None: + """Bind ``local`` to ``source`` read from ``origin``, or refuse a second meaning. + + ``published_as`` is the name ``__all__`` writes for a published + re-export; any other binding is written the way the declarations using + it already write it. + """ + origin_key = origin.lstrip(".").casefold() + if origin_key == self._module.name.casefold() or _identity(origin_key, source) in self._declared: + return + key = self._key(local) + identity = (origin_key, self._key(source)) + existing = self._bound.get(key) + if existing == identity: + return + if existing is not None or key in self._declared_names: + raise ValueError( + f"Contract for {self._module.name!r} cannot bind {local!r} to {origin}.{source}: " + "the name already means something else there" + ) + self._bound[key] = identity + written = f".{origin}" if self._native and not origin.startswith(".") else origin + statement = self._from.get(written) + if statement is None: + statement = self._from[written] = models.SemanticImport(module=written) + self._statements.append(statement) + contract_source = self._contract_source(origin_key, source, verbatim) + contract_target = published_as or local + statement.items.append( + models.SemanticImportItem( + source=source, + target=None if local == source else local, + contract_source=contract_source, + contract_target=None if contract_target == contract_source else contract_target, + ) + ) + + def _contract_source(self, origin_key: str, source: str, verbatim: bool) -> str: + """Return the name the module read from declares for ``source``. + + Its own completion settled that. A module outside this completion is + spelled the way this one spells a name, except a prototype or a module, + which keep their spelling everywhere; a loaded contract already writes + Python and keeps every spelling. + """ + if not self._native: + return source + completed = contract_name_for_source(self._completed.get(origin_key), source) + if completed is not None: + return completed + return source if verbatim else normalize_public_name(source, preserve_case=self._preserve_case).name + + +def _identity(scope: str, name: str) -> tuple[str, str]: + """Return the case-folded ``(module, name)`` identity of one declaration.""" + return str(scope).casefold(), str(name).casefold() + + +def _type_reference(ref: object) -> Iterator[tuple[str, str, str, str]]: + """Yield the binding one external type annotation needs.""" + if not isinstance(ref, dict): + return + origin, source = ref.get("origin_module"), ref.get("name") + local = ref.get("local_name") or source + if not all(isinstance(value, str) and value for value in (origin, source, local)): + return + if ref.get("import_scope") == "procedure": + # A procedure-local type is written qualified by its module. + yield ".", origin, origin, "namespace" + elif "." not in local: + yield origin, source, local, "type" + + +def _prototype_reference(ref: object) -> Iterator[tuple[str, str, str, str]]: + """Yield the binding one callback annotation naming a prototype needs.""" + if not isinstance(ref, dict): + return + origin = str(ref.get("origin_module") or "") + local = str(ref.get("local_name") or ref.get("name") or "") + if origin and local: + yield origin, str(ref.get("name") or local), local, "prototype" + + +def _callable_references(semantic_type: models.SemanticType) -> Iterator[tuple[str, str, str, str]]: + """Yield the binding each callable a declaration expression calls needs.""" + array = semantic_type.storage.array if semantic_type.storage is not None else None + for axis in array.expression_callables if array is not None else (): + for reference in axis: + if reference.native_scope is not None: + local = reference.name.rsplit(".", 1)[-1] + yield reference.native_scope, reference.native_name or local, local, "procedure" diff --git a/prik/policy/exports.py b/prik/policy/exports.py index c12e9fad6..2369ea8d9 100644 --- a/prik/policy/exports.py +++ b/prik/policy/exports.py @@ -114,8 +114,9 @@ def complete_python_export_policy( #: Entity kinds a second namespace cannot publish, whatever it may reach. #: #: A generic dispatcher has no single object another namespace can bind, so it -#: is published where it is declared and nowhere else. -UNPUBLISHABLE_REEXPORT_KINDS = frozenset({"generic"}) +#: is published where it is declared and nowhere else. An intrinsic module's +#: name has no declaration at all, so nothing is there to publish. +UNPUBLISHABLE_REEXPORT_KINDS = frozenset({"generic", "intrinsic"}) def complete_reexport_publication_policy( diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index f9a1e51ff..339ee22eb 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -18,7 +18,6 @@ from prik.codegen.primitive_scalar_types import NumpyDtypeRegistry from prik.contracts import CONTRACT_SYMBOLS, CONTRACT_TYPE_NAMES -from prik.naming.policy import normalize_public_name from prik.utilities.declaration_expressions import fortran_character_value, outside_character_literals from prik.semantics.scalar_types import SEMANTIC_SCALAR_TYPE_NAMES from prik.semantics.ownership_metadata import ( @@ -43,7 +42,6 @@ CONTRACT_BASE_NAMES_METADATA, CONTRACT_NAME_METADATA, CONTRACT_TARGET_NAME_METADATA, - EXTERNAL_TYPE_REF_METADATA, FORTRAN_GENERIC_NAME_METADATA, OVERLOAD_KIND_METADATA, OVERLOAD_TARGET_METADATA, @@ -128,27 +126,6 @@ def contract_import(self) -> str: return f"from {_CONTRACT_MODULE} import {', '.join(items)}" -def contract_name_for_source(completed: dict[str, str] | None, source: object) -> str | None: - """Return the completed contract spelling for one source name. - - A contract records each name exactly as its source spells it, so two - declarations a case-sensitive language keeps apart keep separate entries. - A case-insensitive source may ask under any spelling, which is answered - only when one entry can mean it: where several fold together the request - names no single declaration, and guessing one would depend on the order - they happened to be recorded in. - """ - if not completed: - return None - wanted = str(source) - exact = completed.get(wanted) - if exact is not None: - return exact - folded = wanted.casefold() - matches = [value for key, value in completed.items() if key.casefold() == folded] - return matches[0] if len(matches) == 1 else None - - class PyiPrinter(ClassVisitor): """Emit editable Python stub text from semantic IR models. @@ -164,34 +141,15 @@ class PyiPrinter(ClassVisitor): # Public entrypoints and state # ------------------------------------------------------------------ - def __init__( - self, - *, - normalize_public_names: bool = False, - declared_prototype_names: Iterable[tuple[str, str]] = (), - contract_names_by_module: dict[str, dict[str, str]] | None = None, - ): + def __init__(self, *, normalize_public_names: bool = False): """Configure public-name normalization for independent emissions. Set normalize_public_names when emitting a contract extracted from native source, whose declarations are named in that language rather than in Python. A contract read back from .pyi is already named in - Python and keeps every spelling verbatim. Pass - 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. Pass - contract_names_by_module when imports must read contract spellings - completed for modules rendered in the same operation. + Python and keeps every spelling verbatim. """ self._normalize_public_names = normalize_public_names - self._declared_prototype_names = { - (str(module).casefold(), str(name).casefold()): str(name) for module, name in declared_prototype_names - } - self._contract_names_by_module = { - str(module).casefold(): dict(names) for module, names in (contract_names_by_module or {}).items() - } def emit(self, node) -> str: """Render one supported semantic model to semantic .pyi text. @@ -627,8 +585,6 @@ def _module_exported_names( for reexport in module.reexports: if not reexport.publishes_to_python(): continue - if self._is_source_kind_import(str(reexport.origin_module)): - continue # A prototype keeps its declared spelling wherever it is written, so # the name published for it is the one its import binds. local = str(reexport.local_name) @@ -1533,7 +1489,6 @@ def _fresh_contract_alias(name: str, reserved: set[str]) -> str: def _module_reserved_names(cls, module: SemanticModule) -> set[str]: """Return user/import names that cannot be reused by contract imports.""" names: set[str] = set() - names.update(cls._required_procedure_namespace_import_names(module)) for imp in module.imports: names.update(cls._import_local_names(imp)) for item in [*module.classes, *module.prototypes, *module.variables, *module.functions, *module.overload_sets]: @@ -1578,7 +1533,12 @@ def _import_local_names(imp: str | SemanticImport) -> set[str]: if isinstance(imp, SemanticImport): if not imp.items: return {imp.module.split(".", 1)[0]} - return {item.target or item.source for item in imp.items} + return { + name + for item in imp.items + for name in (item.target or item.source, item.contract_target or item.contract_source) + if name + } names = set() for item in str(imp).split(","): module_name, _, alias = item.strip().partition(" as ") @@ -1591,342 +1551,14 @@ def _append_imports( module: SemanticModule, context: _PyiEmissionContext, ) -> None: - """Append imports.""" + """Append the contract vocabulary import, then each completed import.""" contract_import = context.contract_import() if contract_import: sections.append(contract_import) - imports = self._effective_imports(module) - verbatim = self._verbatim_import_names(module) - reexport_names = { - str(reexport.local_name).casefold(): str(reexport.python_name) - for reexport in module.reexports - if reexport.python_name - } - for imp in imports: - sections.append( - self._emit_import( - imp, - native_source=not module.metadata.get(PYI_LOADED_METADATA), - public_names=context.normalize_public_names, - verbatim_names=verbatim, - contract_names_by_module=self._contract_names_by_module, - reexport_names=reexport_names, - ) - ) - if contract_import or imports: + sections.extend(self._emit_import(imp, context) for imp in module.imports) + if contract_import or module.imports: sections.append("") - @classmethod - def _effective_imports(cls, module: SemanticModule) -> list[str | SemanticImport]: - """Handle effective imports for the current generation context.""" - imports = [ - imp - for imp in module.imports - if not PyiPrinter._is_source_kind_import(imp) and not PyiPrinter._is_contract_import(imp) - ] - procedure_namespaces = cls._required_procedure_namespace_import_names(module) - cls._validate_procedure_namespace_imports(module, procedure_namespaces, imports) - 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_reexport_imports(module, imports)) - imports.extend(cls._missing_procedure_namespace_imports(procedure_namespaces, satisfied_namespaces)) - return imports - - @classmethod - def _missing_reexport_imports( - cls, - module: SemanticModule, - imports: list[str | SemanticImport], - ) -> list[SemanticImport]: - """Return imports binding published names no import already names. - - A plain ``use`` carries every public name of the module it reads, so a - name published through one is not written in any import list. The - contract has to name it explicitly, because a published name must be - one the contract itself reaches. - """ - bound = { - (item.target or item.source).casefold() - for imported in imports - if isinstance(imported, SemanticImport) - for item in imported.items - } - required: dict[str, list[SemanticImportItem]] = {} - for reexport in module.reexports: - local = str(reexport.local_name) - # An intrinsic module has no contract to read a name from, so a - # name published out of one states nothing this contract can bind. - if cls._is_source_kind_import(str(reexport.origin_module)): - continue - if local.casefold() in bound or not reexport.origin_module: - continue - source = str(reexport.source_name) or local - required.setdefault(str(reexport.origin_module), []).append( - SemanticImportItem(source=source, target=local if local != source else None) - ) - bound.add(local.casefold()) - return [SemanticImport(module=name, items=items) for name, items in required.items()] - - @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, - module: SemanticModule, - imports: list[str | SemanticImport], - ) -> list[SemanticImport]: - """Return explicit imports needed to preserve declaration-call origins. - - The semantic array provenance is consumed without changing its call - expression. Existing explicit imports win; wildcard-like native module - imports gain only the specific callable names needed by the generated - contract, which makes a later `.pyi` load unambiguous. - """ - existing = { - (item.target or item.source).casefold(): (imported.module, item.source) - for imported in imports - if isinstance(imported, SemanticImport) - for item in imported.items - } - local_names = {function.name.casefold() for function in module.functions} - required: dict[str, list[SemanticImportItem]] = {} - for semantic_type in _module_semantic_types(module): - storage = semantic_type.storage - array = storage.array if storage is not None else None - if array is None: - continue - for axis_references in array.expression_callables: - for reference in axis_references: - if reference.native_scope is None or reference.name.casefold() in local_names: - continue - local_name = reference.name.rsplit(".", 1)[-1] - native_name = reference.native_name or local_name - previous = existing.get(local_name.casefold()) - if previous is not None: - if previous != (reference.native_scope, native_name): - raise ValueError( - f"Declaration-expression callable import collides with existing name: {local_name!r}" - ) - continue - required.setdefault(reference.native_scope, []).append( - SemanticImportItem( - source=native_name, - target=local_name if local_name != native_name else None, - ) - ) - existing[local_name.casefold()] = (reference.native_scope, native_name) - return [SemanticImport(module=module_name, items=items) for module_name, items in required.items()] - - @classmethod - def _synthetic_flat_external_type_imports( - cls, - module: SemanticModule, - imports: list[str | SemanticImport], - procedure_namespaces: set[str], - ) -> list[SemanticImport]: - """Return synthetic flattened imports needed by external type refs.""" - imported_items = { - (imp.module, item.source, item.target or item.source) - for imp in imports - if isinstance(imp, SemanticImport) - for item in imp.items - } - synthetic: dict[str, list[SemanticImportItem]] = {} - for semantic_type in _module_semantic_types(module): - ref = cls._flat_external_type_import_ref(semantic_type) - if ref is None: - continue - origin_module, source_name, local_name = ref - key = (origin_module, source_name, local_name) - if key in imported_items: - continue - if local_name in procedure_namespaces: - raise ValueError( - f"Procedure-local Fortran import namespace collides with generated .pyi name: {local_name!r}" - ) - synthetic.setdefault(origin_module, []).append( - SemanticImportItem( - source=source_name, - target=local_name if local_name != source_name else None, - ) - ) - imported_items.add(key) - return [ - SemanticImport( - module=module_name, - items=sorted(items, key=lambda item: (item.source, item.target or "")), - ) - for module_name, items in sorted(synthetic.items()) - ] - - @classmethod - def _flat_external_type_import_ref(cls, semantic_type: SemanticType) -> tuple[str, str, str] | None: - """Return flattened external type import fields, or None for qualified refs.""" - ref = semantic_type.metadata.get(EXTERNAL_TYPE_REF_METADATA) - if not isinstance(ref, dict) or cls._is_procedure_local_external_ref(ref): - return None - origin_module = ref.get("origin_module") - source_name = ref.get("name") - local_name = ref.get("local_name") or source_name - if not all(isinstance(value, str) and value for value in (origin_module, source_name, local_name)): - return None - if "." in local_name: - return None - return origin_module, source_name, local_name - - @staticmethod - def _missing_procedure_namespace_imports( - procedure_namespaces: set[str], - satisfied_namespaces: set[str], - ) -> list[SemanticImport]: - """Return missing namespace imports for procedure-local external refs.""" - missing_namespaces = sorted(procedure_namespaces - satisfied_namespaces) - if not missing_namespaces: - return [] - return [ - SemanticImport( - module=".", - items=[SemanticImportItem(source=name) for name in missing_namespaces], - ) - ] - - @staticmethod - def _is_procedure_local_external_ref(ref: dict[object, object]) -> bool: - """Return whether an external ref came from a procedure-local Fortran use.""" - return ref.get("import_scope") == "procedure" - - @classmethod - def _required_procedure_namespace_import_names(cls, module: SemanticModule) -> set[str]: - """Return module namespaces required by procedure-local imported types.""" - names: set[str] = set() - for semantic_type in _module_semantic_types(module): - ref = semantic_type.metadata.get(EXTERNAL_TYPE_REF_METADATA) - if not isinstance(ref, dict) or not cls._is_procedure_local_external_ref(ref): - continue - origin_module = ref.get("origin_module") - source_name = ref.get("name") - local_name = ref.get("local_name") - if not all(isinstance(value, str) and value for value in (origin_module, source_name, local_name)): - continue - names.add(origin_module) - return names - - @classmethod - def _validate_procedure_namespace_imports( - cls, - module: SemanticModule, - procedure_namespaces: set[str], - imports: list[str | SemanticImport], - ) -> None: - """Reject namespace imports that would collide with emitted public names.""" - if not procedure_namespaces: - return - declaration_collisions = procedure_namespaces & cls._top_level_declaration_names(module) - import_collisions = { - name - for imp in imports - for name in cls._import_local_names(imp) & procedure_namespaces - if not cls._import_satisfies_procedure_namespace(imp, name) - } - collisions = sorted(declaration_collisions | import_collisions) - if collisions: - joined = ", ".join(repr(name) for name in collisions) - raise ValueError(f"Procedure-local Fortran import namespace collides with generated .pyi name: {joined}") - - @staticmethod - def _top_level_declaration_names(module: SemanticModule) -> set[str]: - """Return names emitted in a module-level stub namespace.""" - return { - str(getattr(item, "metadata", {}).get(CONTRACT_NAME_METADATA, item.name)) - for item in [ - *module.classes, - *module.prototypes, - *module.variables, - *module.functions, - *module.overload_sets, - ] - if getattr(item, "name", None) - } - - @classmethod - def _satisfied_procedure_namespace_import_names( - cls, - imports: list[str | SemanticImport], - procedure_namespaces: set[str], - ) -> set[str]: - """Return procedure namespace imports already provided by module imports.""" - return { - name - for name in procedure_namespaces - if any(cls._import_satisfies_procedure_namespace(imp, name) for imp in imports) - } - - @staticmethod - def _import_satisfies_procedure_namespace(imp: str | SemanticImport, name: str) -> bool: - """Return whether an import binds exactly the required module namespace.""" - if isinstance(imp, SemanticImport): - if not imp.items: - return imp.module == name - return imp.module == "." and any(item.source == name and item.target is None for item in imp.items) - for item in str(imp).split(","): - module_name, _, alias = item.strip().partition(" as ") - if alias: - continue - if module_name == name: - return True - return False - - @staticmethod - def _is_source_kind_import(imp: str | SemanticImport) -> bool: - """Return whether an import only names a source-language kind module.""" - module = imp.module if isinstance(imp, SemanticImport) else str(imp).split()[0] - return module.casefold().lstrip(".") in {"iso_c_binding", "iso_fortran_env"} - - @staticmethod - def _is_contract_import(imp: str | SemanticImport) -> bool: - """Return whether an import names the generated contract namespace.""" - module = imp.module if isinstance(imp, SemanticImport) else str(imp).split()[0] - return module == _CONTRACT_MODULE - @staticmethod def _has_overload_sets(module: SemanticModule) -> bool: """Return whether has overload sets.""" @@ -1938,123 +1570,27 @@ def class_has_overloads(cls: SemanticClass) -> bool: class_has_overloads(cls) for cls in module.classes if isinstance(cls, SemanticClass) ) - @staticmethod - def _emit_import( - imp: str | SemanticImport, - *, - native_source: bool = False, - public_names: bool = False, - verbatim_names: dict[tuple[str, str], str] | None = None, - contract_names_by_module: dict[str, dict[str, str]] | None = None, - reexport_names: dict[str, str] | None = None, - ) -> str: - """Emit import syntax.""" + @classmethod + def _emit_import(cls, imp: str | SemanticImport, context: _PyiEmissionContext) -> str: + """Emit one import statement as completion spelled it.""" if isinstance(imp, str): return f"import {imp}" if not imp.items: return f"import {imp.module}" - source_module = imp.module.lstrip(".").casefold() - contract_names = (contract_names_by_module or {}).get(source_module) - items = ", ".join( - PyiPrinter._emit_import_item( - item, - public_names=public_names, - verbatim_names=verbatim_names, - source_module=source_module, - contract_names=contract_names, - reexport_names=reexport_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}" + return f"from {imp.module} import {', '.join(cls._emit_import_item(item, context) for item in imp.items)}" @staticmethod - def _emit_import_item( - item: SemanticImportItem, - *, - public_names: bool = False, - verbatim_names: dict[tuple[str, str], str] | None = None, - source_module: str = "", - contract_names: dict[str, str] | None = None, - reexport_names: dict[str, str] | None = None, - ) -> 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 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. - declared = verbatim_names or {} - local = item.target or item.source - prototype = declared.get((source_module, item.source.casefold())) or declared.get( - (source_module, local.casefold()) - ) - if prototype is not None: - # A prototype keeps its declared spelling on both sides, because an - # annotation naming it is written exactly that way. - return prototype if local == prototype else f"{prototype} as {local}" - # The name read from the other contract is the one it published; the - # name bound here is what this contract calls the entity. They part - # company when a rename says so, and also when a collision moved the - # published name aside. - source = PyiPrinter._public_import_name(item.source, public_names=public_names) - if public_names and contract_names: - source = contract_name_for_source(contract_names, item.source) or source - # A name this module publishes is bound under the name export policy - # completed for it, which a collision with one of this module's own - # declarations may have moved aside. - bound = (reexport_names or {}).get(local.casefold()) if public_names else None - bound = bound or PyiPrinter._public_import_name(local, public_names=public_names) - if bound and bound != source: - return f"{source} as {bound}" - return 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) -> dict[tuple[str, str], str]: - """Map prototype identities to the spelling their contract declares. - - 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. 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. - Fortran reaches a name without regard to case, so the identity is - matched that way and the declared spelling is what the mapping returns. - 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 = dict(self._declared_prototype_names) - for prototype in module.prototypes: - names[(module.name.casefold(), str(prototype.name).casefold())] = str(prototype.name) - 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") - declared_name = reference.get("name") or local_name - origin = reference.get("origin_module") - if local_name and origin: - # Both spellings identify the same prototype, and each maps to - # the one its declaring contract writes. - scope = str(origin).casefold() - names.setdefault((scope, str(local_name).casefold()), str(declared_name)) - names.setdefault((scope, str(declared_name).casefold()), str(declared_name)) - return names + def _emit_import_item(item: SemanticImportItem, context: _PyiEmissionContext) -> str: + """Emit one imported name, as the sources or the completed contracts spell it.""" + if not context.normalize_public_names: + source, bound = item.source, item.target + elif item.contract_source is None: + raise ValueError( + f"Contract import of {item.source!r} is incomplete; run complete_contract_imports before emission" + ) + else: + source, bound = item.contract_source, item.contract_target + return f"{source} as {bound}" if bound and bound != source else source def _append_items(self, sections: list[str], items: list, emit_item) -> None: """Append items.""" @@ -2933,28 +2469,15 @@ def _parameter_target(name: str) -> str: _DEFAULT_PRINTER = PyiPrinter() -def emit_module( - module: SemanticModule, - *, - normalize_public_names: bool = False, - declared_prototype_names: Iterable[tuple[str, str]] = (), - contract_names_by_module: dict[str, dict[str, str]] | None = None, -) -> str: +def emit_module(module: SemanticModule, *, normalize_public_names: bool = False) -> str: """Render one semantic module through the shared default printer. - Use this convenience entrypoint for ordinary one-module emission. Set - normalize_public_names when the module is named in its own source language - rather than in Python, declared_prototype_names to name the prototypes the - modules rendered alongside this one declare, and contract_names_by_module - to state the spelling each of those modules published its names under. - Every path creates a fresh module emission context. + Set normalize_public_names when the module is named in its own source + language rather than in Python. Every path creates a fresh module emission + context. """ - if normalize_public_names or declared_prototype_names or contract_names_by_module: - return PyiPrinter( - normalize_public_names=normalize_public_names, - declared_prototype_names=declared_prototype_names, - contract_names_by_module=contract_names_by_module, - ).emit(module) + if normalize_public_names: + return PyiPrinter(normalize_public_names=True).emit(module) return _DEFAULT_PRINTER.emit(module) diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index e4e8e3c5b..89e594ecd 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -15,7 +15,7 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Iterator from typing import NamedTuple from copy import deepcopy from dataclasses import dataclass, replace @@ -1706,7 +1706,7 @@ def _visit_FortranModule( overload_sets=overload_sets, classes=semantic_classes, variables=module_variables + enum_constants, - imports=self._module_imports(module), + imports=self._module_imports(module, index), reexports=self._module_reexports(module, index), metadata=metadata, origin=SemanticOrigin( @@ -2047,6 +2047,54 @@ def _use_associated_names( """ return ScopeUses(module.uses).accessible_names(cls._offered_names(index)) + @classmethod + def _use_associations( + cls, + module: FortranModule, + index: dict[str, FortranModule], + ) -> Iterator[tuple[str, tuple[str, ...], tuple[str, str, str]]]: + """Yield each use-associated name, the modules routing it, and what it names. + + The entity is ``(kind, declaring module, declared name)``, followed back + through every module re-exporting it. A name this module declares as + well, or one naming two entities, has no single association to report. + """ + declared = cls._module_declared_names(module) + for local_name in cls._use_associated_names(module, index): + routes = cls._name_routes(module, index, local_name) + if local_name.casefold() in declared or not routes: + continue + origin = cls._reconcile_routes( + [cls._resolve_reexport_origin(index, route.module, route.source_name) for route in routes] + ) + if origin is not None: + yield local_name, tuple(dict.fromkeys(route.module for route in routes)), origin + + @classmethod + def _module_imports( + cls, + module: FortranModule, + index: dict[str, FortranModule], + ) -> list[SemanticImport]: + """Return the use associations this module's declarations are written with. + + A contract binds the names its declarations use, and only those. A + ``use`` that extends a generic the module declares, or reaches a name no + declaration mentions, binds nothing a contract writes, and a name from a + module the compiler supplies has no contract to be read from. Each name + is read from the module declaring it, however many modules it passed + through, so every reference to one entity binds it the same way. + """ + dependencies = cls._module_declaration_dependencies(module) + imports: dict[str, SemanticImport] = {} + for local_name, _routes, (kind, origin_module, origin_name) in cls._use_associations(module, index): + if local_name.casefold() not in dependencies or kind == "intrinsic": + continue + imports.setdefault(origin_module.casefold(), SemanticImport(module=origin_module)).items.append( + SemanticImportItem(source=origin_name, target=None if origin_name == local_name else local_name) + ) + return list(imports.values()) + def _module_reexports( cls, module: FortranModule, @@ -2060,37 +2108,25 @@ def _module_reexports( Declaration use is recorded for later Python publication policy but does not change this Fortran accessibility decision. """ - declared = cls._module_declared_names(module) is_public = cls._effective_accessibility(module) dependencies = cls._module_declaration_dependencies(module) explicit_public = {str(name).casefold() for name in module.public_symbols} - index = module_index or {} - reexports: list[SemanticReexport] = [] - for local_name in cls._use_associated_names(module, index): - local_key = local_name.casefold() - routes = cls._name_routes(module, index, local_name) - route_names = tuple(dict.fromkeys(route.module for route in routes)) - if local_key in declared or not routes or not is_public(local_name, route_names): - continue - origin = cls._reconcile_routes( - [cls._resolve_reexport_origin(index, route.module, route.source_name) for route in routes] + return [ + SemanticReexport( + local_name, + origin_module, + origin_name, + module.name, + entity_kind=kind, + access_modules=list(route_names), + declaration_dependency=local_name.casefold() in dependencies, + explicitly_public=local_name.casefold() in explicit_public, ) - if origin is None: - continue - kind, origin_module, origin_name = origin - reexports.append( - SemanticReexport( - local_name, - origin_module, - origin_name, - module.name, - entity_kind=kind, - access_modules=list(route_names), - declaration_dependency=local_key in dependencies, - explicitly_public=local_key in explicit_public, - ) + for local_name, route_names, (kind, origin_module, origin_name) in cls._use_associations( + module, module_index or {} ) - return reexports + if is_public(local_name, route_names) + ] @classmethod def _resolve_reexport_origin( @@ -2116,6 +2152,10 @@ def _resolve_reexport_origin( naming different declarations leave the origin genuinely ambiguous there, exactly as they would in the importing module. """ + if module_name.casefold() in _INTRINSIC_FORTRAN_MODULES: + # The compiler supplies it: there is no declaration to name, and no + # contract a name could be read from. + return "intrinsic", module_name, source_name key = (module_name.casefold(), source_name.casefold()) declaring = index.get(module_name.casefold()) if declaring is None or key in seen: @@ -2158,29 +2198,6 @@ def _declared_entity_kind(declaring: FortranModule | None, source_name: str) -> return "variable" return "unknown" - @staticmethod - def _module_imports(module: FortranModule) -> list[str | SemanticImport]: - """Translate each ``use`` association while preserving declaration order. - - An association that lists names records them; one that also imports all - records the module itself beside them, which is what a bare ``use`` - means on its own. - """ - imports: list[str | SemanticImport] = [] - scope = ScopeUses(module.uses) - for module_name in scope.modules(): - if scope.imports_all(module_name): - imports.append(module_name) - mappings = scope.mappings(module_name) - if mappings: - imports.append( - SemanticImport( - module=module_name, - items=[SemanticImportItem(source=item.source, target=item.target) for item in mappings], - ) - ) - return imports - def _declaration_callable_context( self, module: FortranModule, diff --git a/prik/semantics/models.py b/prik/semantics/models.py index 7e69b5d48..6af9ee701 100644 --- a/prik/semantics/models.py +++ b/prik/semantics/models.py @@ -720,8 +720,18 @@ class SemanticClass: @dataclass class SemanticImportItem: + """One name an import binds, spelled as the sources and the contracts write it. + + ``source`` names the entity the way the module it is read from spells it, + and ``target`` the name bound here when that differs. Contract-import + completion adds the spellings the completed contracts use, which a + source-derived contract writes instead; they stay unset until then. + """ + source: str target: str | None = None + contract_source: str | None = None + contract_target: str | None = None @dataclass @@ -761,7 +771,8 @@ class SemanticReexport: Every other kind -- a callback prototype, a module variable whose state stays live, 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. + would misrepresent it. An ``intrinsic`` name comes from a module the + compiler supplies, which declares nothing a contract could read. """ access_modules: list[str] = field(default_factory=list) diff --git a/tests/fortran/_support/printer_models.py b/tests/fortran/_support/printer_models.py index cd96c020a..b0e699d0b 100644 --- a/tests/fortran/_support/printer_models.py +++ b/tests/fortran/_support/printer_models.py @@ -20,6 +20,7 @@ ) from prik.policy.completion import complete_semantic_policies +from prik.policy.contract_imports import complete_contract_imports from prik.policy.exports import complete_python_export_policy from tests.fortran._support.paths import FORTRAN_ROOT @@ -39,6 +40,7 @@ def generate_pyi(source: str) -> str: smod = fortran_module_to_semantic_module(fmod) complete_python_export_policy(smod) + complete_contract_imports([smod]) return emit_module(smod) diff --git a/tests/fortran/arrays/semantics/test_array_semantics.py b/tests/fortran/arrays/semantics/test_array_semantics.py index e4a908e59..f2fd76fa1 100644 --- a/tests/fortran/arrays/semantics/test_array_semantics.py +++ b/tests/fortran/arrays/semantics/test_array_semantics.py @@ -9,6 +9,7 @@ get_function, ) from prik.semantics.models import SemanticExpressionCallable +from prik.policy.contract_imports import complete_contract_imports from prik.policy.exports import complete_python_export_policy from prik.printers import PyiPrinter from prik.parsers.fortran import parse_fortran_file as parse_fortran_source @@ -173,6 +174,7 @@ def test_fortran_inquiries_become_python_array_expressions_and_keep_source_bound "2 + source.shape[1] - 1 if source.shape[1] > 0 else 0", ] complete_python_export_policy(module) + complete_contract_imports([module]) generated = PyiPrinter().emit(module) assert "source.shape[0], max(1, source.shape[1]), source.size, 2 ** source.ndim" in generated assert "2 if source.shape[1] > 0 else 1" in generated @@ -229,6 +231,7 @@ def test_specification_function_calls_keep_local_and_imported_native_identity(): ] complete_python_export_policy(module) + complete_contract_imports([module]) generated = PyiPrinter().emit(module) reloaded = parse_pyi_text(generated, module_name="expression_owner") reloaded_array = get_function(reloaded, "values").return_type.storage.array @@ -269,6 +272,7 @@ def test_wildcard_specification_function_origin_round_trips_unambiguously(): assert array.expression_callables[0][0].native_scope == "extent_helpers" complete_python_export_policy(module) + complete_contract_imports([module]) generated = PyiPrinter().emit(module) reloaded = parse_pyi_text(generated, module_name="expression_owner") reloaded_array = get_function(reloaded, "values").return_type.storage.array 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 6cdaf8e66..4316c819b 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 @@ -311,12 +311,13 @@ def test_renamed_reexport_chain_builds_through_its_generated_contracts(tmp_path: capture_output=True, ) - # Each contract mirrors the `use` its own module wrote. + # Each contract binds the interface where it is declared, under the name + # its own module calls it, however many modules it passed through. 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_declares_mod import OBJ as LOCAL" in consuming assert "calfun: LOCAL" in consuming result = build_pyi_extension( 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 a941d5040..900bc445b 100644 --- a/tests/fortran/data_types/semantics/test_types_and_storage.py +++ b/tests/fortran/data_types/semantics/test_types_and_storage.py @@ -75,7 +75,9 @@ def test_converter_visitor_and_compatibility_methods_cover_public_paths(): assert converter.visit(proc).name == "work" assert converter.visit(proc).visibility == "public" assert converter.visit(dtype, procedure_lookup={}).base_classes == ["base_t"] - assert converter.visit(module).imports[0].items[0].target == "i32" + # No declaration is written with `i32`, and the compiler supplies + # `iso_c_binding`, so the module states no import for its contract. + assert converter.visit(module).imports == [] modules = converter.visit(parsed) assert [module.name for module in modules] == ["m", "standalone_source"] diff --git a/tests/fortran/generic_interfaces/end_to_end/test_merged_generic_replay.py b/tests/fortran/generic_interfaces/end_to_end/test_merged_generic_replay.py new file mode 100644 index 000000000..eab442e91 --- /dev/null +++ b/tests/fortran/generic_interfaces/end_to_end/test_merged_generic_replay.py @@ -0,0 +1,50 @@ +"""A generic merged from several modules builds the same from source and from its contracts.""" + +from pathlib import Path + +import numpy as np +import pytest + +from prik import build_fortran_extension +from tests.fortran._support.wrapper_build import ( + _build_generated_pyi_and_import, + _import_from_build_dir, + _sole_native_module, +) +from tests.fortran.generic_interfaces.semantics.test_generic_contributor_merging import ( + CONTRIBUTORS, + LOCAL_EXTENSION, +) + +pytestmark = pytest.mark.fortran_end_to_end + +MERGED_SOURCE = ( + CONTRIBUTORS + + """ +module facade_mod + use ints_mod, only : convert + use reals_mod, only : convert + implicit none +""" + + LOCAL_EXTENSION +) + + +def _source_build(source: Path, build_dir: Path): + result = build_fortran_extension(source, output_dir=build_dir, output_name="merged_generic") + return _import_from_build_dir(result.module_name, result.output_dir) + + +@pytest.mark.parametrize("lane", ["source", "generated_pyi"]) +def test_merged_generic_dispatches_every_contributor(tmp_path: Path, lane: str): + source = tmp_path / "merged_generic.f90" + source.write_text(MERGED_SOURCE, encoding="utf-8") + if lane == "source": + package = _source_build(source, tmp_path / "source_build") + else: + package = _build_generated_pyi_and_import(source, tmp_path / "pyi_build") + facade = _sole_native_module(package).facade_mod if not hasattr(package, "facade_mod") else package.facade_mod + + assert facade.convert(np.int32(3)) == np.int32(3) + assert facade.convert(np.float32(2.5)) == np.float32(2.5) + assert facade.convert(np.bool_(True)) diff --git a/tests/fortran/generic_interfaces/semantics/test_generic_contributor_merging.py b/tests/fortran/generic_interfaces/semantics/test_generic_contributor_merging.py index b03a65da4..15843a6fb 100644 --- a/tests/fortran/generic_interfaces/semantics/test_generic_contributor_merging.py +++ b/tests/fortran/generic_interfaces/semantics/test_generic_contributor_merging.py @@ -345,3 +345,29 @@ def test_a_type_bound_assignment_reaches_the_method_it_projects(tmp_path: Path): ("self", 0), ("other", None), ] + + +def test_a_contract_binds_a_merged_generic_only_by_declaring_it(tmp_path: Path): + """The facade writes the merged generic, so no `use` of a contributor is imported. + + Mirroring each `use` bound `convert` once per contributor as well as by the + facade's own declaration, and a package binding one name three ways cannot + be read back. + """ + from prik.pipeline.pyi import emit_module_stubs + + modules = _modules( + tmp_path, + CONTRIBUTORS, + """\ +module facade_mod + use ints_mod, only : convert + use reals_mod, only : convert + implicit none +""" + + LOCAL_EXTENSION, + ) + contract = emit_module_stubs(list(modules.values()), normalize_public_names=True)["facade_mod"] + + assert [line for line in contract.splitlines() if line.startswith("from .")] == [] + assert contract.count("def convert(") == 3 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 9417bdc91..8db29890a 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 @@ -4,21 +4,7 @@ "classes": [], "exported_names": null, "functions": [], - "imports": [ - { - "items": [ - { - "source": "c_int", - "target": null - }, - { - "source": "c_double", - "target": null - } - ], - "module": "iso_c_binding" - } - ], + "imports": [], "metadata": {}, "name": "constants_mod", "origin": { @@ -40,7 +26,7 @@ "iso_c_binding" ], "declaration_dependency": true, - "entity_kind": "unknown", + "entity_kind": "intrinsic", "explicitly_public": false, "local_name": "c_int", "module": "constants_mod", @@ -54,7 +40,7 @@ "iso_c_binding" ], "declaration_dependency": true, - "entity_kind": "unknown", + "entity_kind": "intrinsic", "explicitly_public": false, "local_name": "c_double", "module": "constants_mod", 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 040b4a5f8..5ea6809a9 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 @@ -3,7 +3,7 @@ import pytest import prik.pipeline.pyi as pyi_pipeline from prik.parsers.fortran import parse_fortran_file as parse_fortran_source -from prik.printers.pyi import contract_name_for_source +from prik.policy.contract_imports import complete_contract_imports, contract_name_for_source from prik.printers import ( PyiPrinter, emit_module, @@ -132,10 +132,10 @@ def test_printer_validation_and_opaque_dependency_edge_cases(): } }, ) - assert ( - printer._effective_imports(SemanticModule(name="api", variables=[SemanticArgument("value", malformed_import)])) - == [] - ) + malformed_module = SemanticModule(name="api", variables=[SemanticVariable("value", malformed_import)]) + complete_python_export_policy(malformed_module) + complete_contract_imports([malformed_module]) + assert malformed_module.imports == [] invalid_opaque_ref = SemanticType( "external_type", @@ -370,8 +370,7 @@ def test_emit_procedure_local_imported_derived_types_as_qualified_module_refs(): """ ) - module = fortran_module_to_semantic_module(parsed) - code = emit_module(module) + code = emit_module_stubs(fortran_module_to_semantic_module(parsed))["physics"] assert "from . import a_types, b_types" in code assert "p: a_types.state" in code @@ -400,9 +399,10 @@ def test_emit_procedure_local_import_namespace_collision_fails_without_alias(): ) module = fortran_module_to_semantic_module(parsed) + complete_python_export_policy(module) - with pytest.raises(ValueError, match="Procedure-local Fortran import namespace collides"): - emit_module(module) + with pytest.raises(ValueError, match="cannot bind 'a_types'"): + complete_contract_imports([module]) def test_emit_procedure_local_import_namespace_collision_with_synthetic_import_fails(): @@ -439,8 +439,10 @@ def test_emit_procedure_local_import_namespace_collision_with_synthetic_import_f ], ) - with pytest.raises(ValueError, match="Procedure-local Fortran import namespace collides"): - emit_module(module) + complete_python_export_policy(module) + + with pytest.raises(ValueError, match="cannot bind 'a_types'"): + complete_contract_imports([module]) def test_emit_bare_use_adds_import_for_opaque_dependency_type(): @@ -457,22 +459,12 @@ 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"] + # The contract binds the type a declaration names, not the `use` itself. + assert "import types_mod" not in stubs["physics"].splitlines() assert "from .types_mod import particle" in stubs["physics"] assert stubs["types_mod"].endswith('class particle(Opaque):\n pass\n\n__all__ = ["particle"]') -def test_emit_omits_structured_source_kind_import_without_items(): - module = SemanticModule( - name="imports", - imports=[SemanticImport(module="iso_c_binding")], - ) - - code = emit_module(module) - - assert code == "" - - def test_emit_module_aliases_contract_import_when_user_name_collides(): array_type = SemanticType( "Float64", @@ -955,8 +947,9 @@ def test_generated_contract_honors_used_module_accessibility_routes(): stubs = emit_module_stubs(modules, normalize_public_names=True) - assert stubs["route_hidden"].rstrip().endswith("__all__ = []") - assert "from .route_home import x" not in stubs["route_hidden"] + # The route carries nothing a declaration uses or the module publishes, so + # its contract has nothing to write. + assert stubs["route_hidden"] == "" assert "from .route_home import x" in stubs["route_visible"] assert stubs["route_visible"].rstrip().endswith('__all__ = ["x"]') @@ -1039,3 +1032,38 @@ def test_two_spellings_a_case_sensitive_source_keep_distinct_contract_names(): # would depend on the order they were recorded in, so it names neither. assert contract_name_for_source(completed, "FOO") is None assert contract_name_for_source({"foo": "foo", "Foo": "Foo"}, "FOO") is None + + +def test_a_renamed_import_binds_the_name_its_annotations_write(): + """An import binds a name for the declarations that use it, spelled as they do. + + Fortran keeps the case a `use` rename is written in, and the annotation + naming the type writes it that way. Binding the name export policy would + publish it under instead left the annotation naming nothing. + """ + home = parse_fortran_source(""" +module shapes +implicit none +type :: point + integer :: x +end type point +end module shapes +""") + user = parse_fortran_source(""" +module user_mod +use shapes, only : MyPoint => point +implicit none +contains +subroutine move(p) +type(MyPoint), intent(inout) :: p +end subroutine move +end module user_mod +""") + + stubs = emit_module_stubs( + [fortran_module_to_semantic_module(item) for item in (home, user)], + normalize_public_names=True, + ) + + assert "from .shapes import Point as MyPoint" in stubs["user_mod"] + assert "p: MyPoint" in stubs["user_mod"] 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 bdc595c62..3cf50ff57 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 @@ -5,6 +5,8 @@ given, strategies as st, ) +from prik.policy.contract_imports import complete_contract_imports +from prik.policy.exports import complete_python_export_policy from prik.printers import emit_module from prik.pipeline.pyi import pyi_text_to_semantic_module as parse_pyi_text from prik.semantics.models import ( @@ -71,6 +73,8 @@ def import_lines(names): for index, type_name in enumerate(names) ], ) + complete_python_export_policy(module) + complete_contract_imports([module]) return [line for line in emit_module(module).splitlines() if line.startswith("from ")] expected = [f"from .types import {', '.join(sorted(type_names))}"] diff --git a/tests/fortran/modules/semantics/test_declaration_publication.py b/tests/fortran/modules/semantics/test_declaration_publication.py index 91070cbf6..b96541463 100644 --- a/tests/fortran/modules/semantics/test_declaration_publication.py +++ b/tests/fortran/modules/semantics/test_declaration_publication.py @@ -11,6 +11,7 @@ from prik.parsers.fortran import parse_fortran_file, parse_fortran_project from prik.printers.pyi import PyiPrinter +from prik.policy.contract_imports import complete_contract_imports from prik.policy.exports import complete_python_export_policy from prik.semantics.fortran2ir import fortran_file_to_semantic_modules, fortran_project_to_semantic_modules @@ -278,6 +279,7 @@ def test_a_prototype_does_not_take_a_name_the_module_imports(tmp_path: Path): assert [(item.native_name, item.declaring_scope) for item in module.prototypes] == [("cb", ("first",))] assert module.prototypes[0].name != "first_cb" + complete_contract_imports([module]) contract = PyiPrinter(normalize_public_names=True).emit(module) assert "from .helper_mod import first_cb" in contract assert f"def {module.prototypes[0].name}(" in contract diff --git a/tests/fortran/modules/semantics/test_modules_and_imports.py b/tests/fortran/modules/semantics/test_modules_and_imports.py index fcf0f4106..b1582954a 100644 --- a/tests/fortran/modules/semantics/test_modules_and_imports.py +++ b/tests/fortran/modules/semantics/test_modules_and_imports.py @@ -306,7 +306,9 @@ def test_fortran_to_ir_preserves_module_semantics_from_inline_source(): assert array_contract(semantic_arg.semantic_type).allocatable is True assert semantic_proc.projection[0].python_position == 0 assert semantic_dtype.base_classes == ["base"] - assert semantic_module.imports == ["iso_c_binding"] + # No declaration is written with a name `use iso_c_binding` supplies, and a + # compiler-supplied module has no contract to read one from. + assert semantic_module.imports == [] assert semantic_dtype.visibility == "private" assert semantic_proc.visibility == "public" assert semantic_file_modules[0].name == "m" From 7fbd8aa28fc0dfee09aca86aabab44fda46a0566 Mon Sep 17 00:00:00 2001 From: said Date: Sat, 19 Sep 2026 06:45:10 +0100 Subject: [PATCH 78/96] Spell each imported name one way throughout its contract A type a module used in a signature and also published was spelled by two decisions: export naming gave the published name a class spelling for __all__, while the annotation, which no completion reached, wrote the use statement's spelling. One import can bind only one of them, so either the annotation or __all__ named something the contract never bound, and the generated package could not be read back (from .shapes import Point beside p: point). Naming completion now records one spelling for every name a module imports, under CONTRACT_IMPORT_NAMES_METADATA: a re-export's completed name, or, for a type the module does not re-export, a class spelling held in the same ledger. Type annotations, base classes, the import binding, and __all__ all read it. A re-export is spelled by its entity whether or not it is published -- a type as a class, a prototype as declared -- so the dependency-only branch and the printer's and planner's prototype special cases are gone. A name the module's own declarations use as a type is spelled as one even where its declaring module was not read. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 18 ++- docs/developer/packages/policy.md | 5 +- prik/policy/contract_imports.py | 72 +++------ prik/policy/exports.py | 146 +++++++++++++++--- prik/printers/pyi.py | 11 +- prik/semantics/models.py | 3 + .../end_to_end/test_type_accessibility.py | 5 +- .../combined_modules/box_ops.pyi | 6 +- .../end_to_end/test_multi_source_builds.py | 2 +- .../test_pyi_printer_imports_and_packages.py | 58 +++++-- 10 files changed, 212 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fa99ffa6..2f3ea9e46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,19 @@ release tags add a leading `v` to the package version. `complete_contract_imports()`: a contract binds the names its declarations use and the names it publishes, each from the module declaring it, and a name reached twice through re-exporting modules binds once. The `.pyi` printer - renders those statements and no longer chooses or spells an import. A renamed - import written with capitals (`use shapes, only : MyPoint => point`) now binds - the name its annotations use. A `use` whose names no declaration mentions and - the module does not publish, such as a bare `use types_mod`, is no longer - written into the contract. + renders those statements and no longer chooses or spells an import. A `use` + whose names no declaration mentions and the module does not publish, such as + a bare `use types_mod`, is no longer written into the contract. + +- An imported name is spelled one way throughout a contract: the way the module + publishes it. A type the module used in a signature and also published was + imported under one spelling while `__all__` or the annotation wrote another + (`from .shapes import Point` beside `p: point`), so the generated package + could not be read back. The import, the annotation, and `__all__` now read + one completed name, and a type is spelled as a class whether or not the + module publishes it: `from .shapes import Point` and `p: Point`, or + `from .shapes import Point as Mypoint` for `use shapes, only : MyPoint => + point`. A prototype keeps the spelling it is declared with everywhere. - A contract publishes exactly its `__all__`. A type it left out -- such as the sibling type a leaf contract imports for its signatures -- was still diff --git a/docs/developer/packages/policy.md b/docs/developer/packages/policy.md index 85e63cd9c..71f5abe46 100644 --- a/docs/developer/packages/policy.md +++ b/docs/developer/packages/policy.md @@ -168,7 +168,10 @@ then records zero or more public placements independently. spelling is read with `completed_contract_name()`, which lives beside `CONTRACT_NAME_METADATA` in `prik/semantics/models.py` so contract emission can read the decision without importing policy; class-surface construction reads it -the same way. +the same way. A name the module imports is completed in the same ledger, as the +module publishes it or, for a type it does not publish, as a class, and +recorded under `CONTRACT_IMPORT_NAMES_METADATA`; its annotations, its import, +and `__all__` all read that one spelling. `complete_contract_imports()` runs once names are complete, over the modules written together. A contract binds what its declarations name and what it diff --git a/prik/policy/contract_imports.py b/prik/policy/contract_imports.py index 8683260b5..844a7c6e6 100644 --- a/prik/policy/contract_imports.py +++ b/prik/policy/contract_imports.py @@ -18,7 +18,7 @@ from collections.abc import Iterable, Iterator from prik.naming import normalize_public_name, preserves_source_case -from prik.policy.exports import contract_names_by_source +from prik.policy.exports import contract_name_for_source, contract_names_by_source, imported_type_reference from prik.semantics import models from prik.semantics.pyi_metadata import PYI_LOADED_METADATA @@ -41,27 +41,6 @@ def complete_contract_imports( module.imports = _ContractImports(module, completed).bindings() -def contract_name_for_source(completed: dict[str, str] | None, source: object) -> str | None: - """Return the completed contract spelling for one source name. - - A contract records each name exactly as its source spells it, so two - declarations a case-sensitive language keeps apart keep separate entries. - A case-insensitive source may ask under any spelling, which is answered - only when one entry can mean it: where several fold together the request - names no single declaration, and guessing one would depend on the order - they happened to be recorded in. - """ - if not completed: - return None - wanted = str(source) - exact = completed.get(wanted) - if exact is not None: - return exact - folded = wanted.casefold() - matches = [value for key, value in completed.items() if key.casefold() == folded] - return matches[0] if len(matches) == 1 else None - - class _ContractImports: """Bind each name one contract needs, once, from the one entity it names. @@ -78,6 +57,8 @@ def __init__(self, module: models.SemanticModule, completed: dict[str, dict[str, self._native = not module.metadata.get(PYI_LOADED_METADATA) self._preserve_case = not self._native or preserves_source_case(module.origin.source_language) self._key = str if self._preserve_case else str.casefold + # The one spelling naming completed for each name this contract imports. + self._imported = module.metadata.get(models.CONTRACT_IMPORT_NAMES_METADATA, {}) if self._native else {} declarations = (*module.functions, *module.classes, *module.variables, *module.prototypes) self._declared_names = {self._key(str(item.name)) for item in (*declarations, *module.overload_sets)} self._declared = { @@ -102,16 +83,13 @@ def bindings(self) -> list[str | models.SemanticImport]: for statement in self._module.imports: self._stated(statement) for reexport in self._module.reexports: - # A name published out of another module is bound under the name - # the contract publishes it by; a prototype keeps its spelling. + # A name published out of another module is bound to be published. if reexport.publishes_to_python() and reexport.origin_module: - prototype = reexport.entity_kind == "prototype" self._bind( str(reexport.origin_module), str(reexport.source_name or reexport.local_name), str(reexport.local_name), - verbatim=prototype, - published_as=None if prototype else str(reexport.python_name), + verbatim=reexport.entity_kind == "prototype", ) for origin, source, local, kind in sorted(set(self._references())): self._bind(origin, source, local, verbatim=kind in {"prototype", "namespace"}) @@ -128,24 +106,17 @@ def _stated(self, statement: str | models.SemanticImport) -> None: def _references(self) -> Iterator[tuple[str, str, str, str]]: """Yield ``(module, source, local, kind)`` for each name a declaration names.""" for semantic_type in models._module_semantic_types(self._module): - yield from _type_reference(semantic_type.metadata.get(models.EXTERNAL_TYPE_REF_METADATA)) + yield from _type_reference(semantic_type) yield from _prototype_reference(semantic_type.metadata.get(models.PROTOTYPE_REF_METADATA)) yield from _callable_references(semantic_type) - def _bind( - self, - origin: str, - source: str, - local: str, - *, - verbatim: bool = False, - published_as: str | None = None, - ) -> None: + def _bind(self, origin: str, source: str, local: str, *, verbatim: bool = False) -> None: """Bind ``local`` to ``source`` read from ``origin``, or refuse a second meaning. - ``published_as`` is the name ``__all__`` writes for a published - re-export; any other binding is written the way the declarations using - it already write it. + The contract binds the spelling naming completed for ``local``, which is + the one its annotations and ``__all__`` write. A name completion did not + spell -- a prototype, or a callable a declaration expression writes -- + keeps the spelling it is written with. """ origin_key = origin.lstrip(".").casefold() if origin_key == self._module.name.casefold() or _identity(origin_key, source) in self._declared: @@ -167,7 +138,7 @@ def _bind( statement = self._from[written] = models.SemanticImport(module=written) self._statements.append(statement) contract_source = self._contract_source(origin_key, source, verbatim) - contract_target = published_as or local + contract_target = contract_name_for_source(self._imported, local) or local statement.items.append( models.SemanticImportItem( source=source, @@ -198,19 +169,16 @@ def _identity(scope: str, name: str) -> tuple[str, str]: return str(scope).casefold(), str(name).casefold() -def _type_reference(ref: object) -> Iterator[tuple[str, str, str, str]]: - """Yield the binding one external type annotation needs.""" - if not isinstance(ref, dict): - return - origin, source = ref.get("origin_module"), ref.get("name") - local = ref.get("local_name") or source - if not all(isinstance(value, str) and value for value in (origin, source, local)): +def _type_reference(semantic_type: models.SemanticType) -> Iterator[tuple[str, str, str, str]]: + """Yield the binding one annotation naming an imported type needs.""" + reference = imported_type_reference(semantic_type) + if reference is None: return - if ref.get("import_scope") == "procedure": + if reference.procedure_local: # A procedure-local type is written qualified by its module. - yield ".", origin, origin, "namespace" - elif "." not in local: - yield origin, source, local, "type" + yield ".", reference.module, reference.module, "namespace" + else: + yield reference.module, reference.name, reference.local, "type" def _prototype_reference(ref: object) -> Iterator[tuple[str, str, str, str]]: diff --git a/prik/policy/exports.py b/prik/policy/exports.py index 2369ea8d9..5e42817da 100644 --- a/prik/policy/exports.py +++ b/prik/policy/exports.py @@ -14,6 +14,7 @@ class members still need a contract identity even when they publish nothing. from __future__ import annotations from dataclasses import dataclass +from typing import NamedTuple from prik.naming import NamingPolicy, normalize_public_name, preserves_source_case from prik.semantics import models @@ -160,10 +161,17 @@ def _complete_reexport_names( Published associations add runtime attributes; dependency-only associations still add contract imports. Both compete with declarations for a Python - spelling, so the same ledger names them after the module's declarations. A - dependency keeps an ordinary import-binding spelling even when the entity - is a type; only a published type receives class-style capitalization. + spelling, so the same ledger names them after the module's declarations. + The spelling follows the entity, whether or not it is published: a type is + spelled as a class wherever it is written, and a prototype keeps the + spelling it is declared with. A name the module's own declarations use as a + type is one, even where the module declaring it was not read. """ + types = { + reference.local.casefold() + for reference in map(imported_type_reference, models._module_semantic_types(module)) + if reference is not None and not reference.procedure_local + } for reexport in module.reexports: if reexport.python_name: continue @@ -173,19 +181,20 @@ def _complete_reexport_names( if completed_name is not None: reexport.python_name = completed_name continue - category = ( - { - "derived_type": "class", - "variable": "variable", - }.get(reexport.entity_kind, "function") - if published - else "function" - ) + namespace = _reexport_namespace(module, reexport) + owner = f"re-export {reexport.local_name}" + if reexport.entity_kind == "prototype": + reexport.python_name = naming.hold_completed_public_name( + namespace, reexport.local_name, category="function", owner=owner + ) + continue + kind = "derived_type" if str(reexport.local_name).casefold() in types else reexport.entity_kind + category = {"derived_type": "class", "variable": "variable"}.get(kind, "function") reexport.python_name = naming.reserve_public_name( - _reexport_namespace(module, reexport), + namespace, reexport.local_name, category="function" if contract_named else category, - owner=f"re-export {reexport.local_name}", + owner=owner, ) @@ -290,6 +299,8 @@ def _complete_contract_names( owner=f"re-export {reexport.local_name}", ) + imported = _complete_imported_names(module, naming, contract_named=contract_named) + for prototype in module.prototypes: completed = str(prototype.name) naming.hold_completed_public_name( @@ -321,7 +332,7 @@ def _complete_contract_names( preserve_case=preserve_case, ) - _complete_local_type_contract_names(module) + _complete_type_reference_names(module, imported) _complete_overload_target_contract_names(module, preserve_case=preserve_case) @@ -411,25 +422,110 @@ def _complete_class_member_contract_names( ) -def _complete_local_type_contract_names(module: models.SemanticModule) -> None: - """Attach local class spellings to every semantic type that names one.""" - by_exact = {str(cls.name): models.completed_contract_name(cls) for cls in _all_classes(module.classes)} - by_folded: dict[str, list[str]] = {} - for source, completed in by_exact.items(): - by_folded.setdefault(source.casefold(), []).append(completed) +def _complete_imported_names( + module: models.SemanticModule, + naming: NamingPolicy, + *, + contract_named: bool, +) -> dict[str, str]: + """Record the one spelling the contract writes for each name it imports. + + A re-export is already named: the name the module publishes it under is the + name the contract binds and writes. A type the module imports without + re-exporting it takes the class spelling a published type would, held + beside the module's own names. A contract that was read already names what + it imports and keeps every spelling. + """ + completed = {str(reexport.local_name): str(reexport.python_name) for reexport in module.reexports} + if not contract_named: + for semantic_type in models._module_semantic_types(module): + reference = imported_type_reference(semantic_type) + if reference is None or reference.procedure_local: + continue + if contract_name_for_source(completed, reference.local) is None: + completed[reference.local] = naming.reserve_public_name( + (), reference.local, category="class", owner=f"import {reference.local}" + ) + module.metadata[models.CONTRACT_IMPORT_NAMES_METADATA] = completed + return completed + + +def _complete_type_reference_names(module: models.SemanticModule, imported: dict[str, str]) -> None: + """Spell every type a declaration names the way the contract binds it. + + A class the module declares is written under its contract name, and an + imported one under the name the module imports it by, so an annotation, the + import binding its name, and ``__all__`` write one spelling. + """ + declared = {str(cls.name): models.completed_contract_name(cls) for cls in _all_classes(module.classes)} for semantic_type in models._module_semantic_types(module): - completed = by_exact.get(str(semantic_type.name)) - if completed is None: - matches = by_folded.get(str(semantic_type.name).casefold(), ()) - completed = matches[0] if len(matches) == 1 else None + reference = imported_type_reference(semantic_type) + if reference is None: + completed = contract_name_for_source(declared, semantic_type.name) + elif not reference.procedure_local: + completed = contract_name_for_source(imported, reference.local) + else: + continue if completed is not None: semantic_type.metadata[models.CONTRACT_NAME_METADATA] = completed + # A base is named, not annotated: the class it names is declared here or imported. for semantic_class in _all_classes(module.classes): semantic_class.metadata[models.CONTRACT_BASE_NAMES_METADATA] = { - base: by_exact.get(base, base) for base in semantic_class.base_classes + base: contract_name_for_source(declared, base) or contract_name_for_source(imported, base) or base + for base in semantic_class.base_classes } +class ImportedTypeReference(NamedTuple): + """One annotation naming a type another module declares.""" + + module: str + name: str + local: str + procedure_local: bool + + +def imported_type_reference(semantic_type: models.SemanticType) -> ImportedTypeReference | None: + """Return the imported type one annotation names, or ``None``. + + A procedure-local type is written qualified by its module, so only the + module is bound for it; a type whose local name is already qualified names + a module the contract imports itself. + """ + ref = semantic_type.metadata.get(models.EXTERNAL_TYPE_REF_METADATA) + if not isinstance(ref, dict): + return None + module, name = ref.get("origin_module"), ref.get("name") + local = ref.get("local_name") or name + if not all(isinstance(value, str) and value for value in (module, name, local)): + return None + procedure_local = ref.get("import_scope") == "procedure" + if not procedure_local and "." in local: + return None + return ImportedTypeReference(module, name, local, procedure_local) + + +def contract_name_for_source(completed: dict[str, str] | None, source: object) -> str | None: + """Return the completed contract spelling for one source name. + + A contract records each name exactly as its source spells it, so two + declarations a case-sensitive language keeps apart keep separate entries. + A case-insensitive source may ask under any spelling, which is answered + only when one entry can mean it: where several fold together the request + names no single declaration, and guessing one would depend on the order + they happened to be recorded in. + """ + if not completed: + return None + wanted = str(source) + exact = completed.get(wanted) + if exact is not None: + return exact + folded = wanted.casefold() + matches = [value for key, value in completed.items() if key.casefold() == folded] + return matches[0] if len(matches) == 1 else None + + def _complete_overload_target_contract_names( module: models.SemanticModule, *, diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index 339ee22eb..46c4acf42 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -583,15 +583,8 @@ def _module_exported_names( if not self._is_private(overload_set) ) for reexport in module.reexports: - if not reexport.publishes_to_python(): - continue - # A prototype keeps its declared spelling wherever it is written, so - # the name published for it is the one its import binds. - local = str(reexport.local_name) - if reexport.entity_kind == "prototype": - names.append(local) - continue - names.append(self._reexport_name(reexport, context)) + if reexport.publishes_to_python(): + names.append(self._reexport_name(reexport, context)) return list(dict.fromkeys(names)) # ------------------------------------------------------------------ diff --git a/prik/semantics/models.py b/prik/semantics/models.py index 6af9ee701..fd07317ba 100644 --- a/prik/semantics/models.py +++ b/prik/semantics/models.py @@ -426,6 +426,9 @@ class ProcedureOverloadSet: CONTRACT_NAME_METADATA = "contract_name" CONTRACT_TARGET_NAME_METADATA = "contract_target_name" CONTRACT_BASE_NAMES_METADATA = "contract_base_names" +#: The one spelling a module's contract writes for each name it imports, keyed +#: by the name its source binds; annotations, imports, and ``__all__`` read it. +CONTRACT_IMPORT_NAMES_METADATA = "contract_import_names" def completed_contract_name(owner, default_name: str | None = None) -> str: diff --git a/tests/fortran/derived_types/end_to_end/test_type_accessibility.py b/tests/fortran/derived_types/end_to_end/test_type_accessibility.py index 8e8e48ed2..7d6e1d9a4 100644 --- a/tests/fortran/derived_types/end_to_end/test_type_accessibility.py +++ b/tests/fortran/derived_types/end_to_end/test_type_accessibility.py @@ -80,7 +80,10 @@ def test_declaration_dependency_accessibility_and_python_publication_are_separat ) consumer_contract = stubs["dependency_consumer"] - assert "from .dependency_home import Box as crate" in consumer_contract + # A renamed type is still a class, spelled as one wherever the contract + # writes it: in its import and in the annotations naming it. + assert "from .dependency_home import Box as Crate" in consumer_contract + assert "item: Crate" in consumer_contract assert consumer_contract.rstrip().endswith('__all__ = ["crate_value"]') assert not any(name.casefold() == "crate" for name in vars(module.dependency_consumer)) 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 adc70844b..bac8d5a7e 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,13 +1,13 @@ from prik.contracts import Addr, Arg, Int32, native_call -from .shared_types import Box as box +from .shared_types import Box def box_value( - item: box + item: Box ) -> Int32: ... @native_call([Addr(Arg(0))]) def boxed( value: Int32 -) -> box: ... +) -> Box: ... __all__ = ["box_value", "boxed"] 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 a11d272cf..42809a47a 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 @@ -305,7 +305,7 @@ def test_multi_source_pyi_out_writes_one_flat_combined_package(tmp_path: Path): "from . import first_math\nfrom . import shared_types\nfrom . import second_math\nfrom . import box_ops\n\n" '__all__ = ["first_math", "shared_types", "second_math", "box_ops"]\n' ) - assert "from .shared_types import Box as box" in (package / "box_ops.pyi").read_text(encoding="utf-8") + assert "from .shared_types import Box\n" 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") 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 5ea6809a9..0e4db70b4 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 @@ -1,9 +1,11 @@ """Tests split by stable ownership concept from `test_imports_and_packages.py`.""" +import json import pytest import prik.pipeline.pyi as pyi_pipeline from prik.parsers.fortran import parse_fortran_file as parse_fortran_source -from prik.policy.contract_imports import complete_contract_imports, contract_name_for_source +from prik.policy.contract_imports import complete_contract_imports +from prik.policy.exports import contract_name_for_source from prik.printers import ( PyiPrinter, emit_module, @@ -920,7 +922,7 @@ def test_generated_contract_states_the_names_its_source_publishes(): # The publishing module names the import; the consuming one does not. assert stubs["surface_facade"].rstrip().endswith('__all__ = ["scale_value"]') assert stubs["surface_consumer"].rstrip().endswith('__all__ = ["crate_value"]') - assert "from .surface_home import Box as crate" in stubs["surface_consumer"] + assert "from .surface_home import Box as Crate" in stubs["surface_consumer"] assert '__all__ = ["Box", "scale_value"]' in stubs["surface_home"] @@ -1034,36 +1036,58 @@ def test_two_spellings_a_case_sensitive_source_keep_distinct_contract_names(): assert contract_name_for_source({"foo": "foo", "Foo": "Foo"}, "FOO") is None -def test_a_renamed_import_binds_the_name_its_annotations_write(): - """An import binds a name for the declarations that use it, spelled as they do. - - Fortran keeps the case a `use` rename is written in, and the annotation - naming the type writes it that way. Binding the name export policy would - publish it under instead left the annotation naming nothing. - """ - home = parse_fortran_source(""" +SHAPES_SOURCE = """ module shapes implicit none type :: point integer :: x end type point end module shapes -""") - user = parse_fortran_source(""" +""" + + +@pytest.mark.parametrize( + ("association", "published", "spelling"), + [ + ("point", True, "Point"), + ("MyPoint => point", True, "Mypoint"), + ("MyPoint => point", False, "Mypoint"), + ], + ids=["published", "renamed-published", "renamed-dependency"], +) +def test_an_imported_type_is_spelled_one_way_throughout_its_contract(association, published, spelling): + """The import, the annotation, and `__all__` write the name the module publishes. + + Naming completion spelled a published type as a class for `__all__` while + the annotation wrote the `use` statement's spelling, so one of them always + named something the contract never bound. A type is spelled as a class + whether or not the module publishes it, and every place reads that name. + """ + local = association.split(" => ")[0] + user = parse_fortran_source(f""" module user_mod -use shapes, only : MyPoint => point +use shapes, only : {association} implicit none +private +public :: {f"{local}, " if published else ""}move contains subroutine move(p) -type(MyPoint), intent(inout) :: p +type({local}), intent(inout) :: p end subroutine move end module user_mod """) stubs = emit_module_stubs( - [fortran_module_to_semantic_module(item) for item in (home, user)], + [ + fortran_module_to_semantic_module(parse_fortran_source(SHAPES_SOURCE)), + fortran_module_to_semantic_module(user), + ], normalize_public_names=True, ) + contract = stubs["user_mod"] - assert "from .shapes import Point as MyPoint" in stubs["user_mod"] - assert "p: MyPoint" in stubs["user_mod"] + bound = "Point" if spelling == "Point" else f"Point as {spelling}" + assert f"from .shapes import {bound}\n" in contract + assert f"p: {spelling}\n" in contract + expected_all = ["move", spelling] if published else ["move"] + assert contract.rstrip().endswith(f"__all__ = {json.dumps(expected_all)}") From 0027bd2fbb930d124895b2572171c2dd46e88a75 Mon Sep 17 00:00:00 2001 From: said Date: Sat, 19 Sep 2026 08:23:22 +0100 Subject: [PATCH 79/96] Spell a declaration-expression call the way the contract binds its callee SemanticExpressionCallable.name is documented as the contract spelling, but it kept the source spelling while contract-import completion bound the callee under its completed name. A specification function named `lambda` was imported as `lambda_` and one colliding with it as `lambda__2`, while the shape still called lambda(n) -- not Python at all -- so such a function could size an array neither from source nor from its contract. The parser could not even read the call: a native name Python reserves failed the expression parse. The declaration-expression parser now sets a reserved native name aside through Python's tokenizer and restores it in the tree, so the call is read. Contract-name completion sets each callable reference's name to the name the contract binds the callee by, and respells that call in the public shape through the parsed expression, changing call targets only; native_name and native_scope keep reaching the Fortran function. Imported calls are spelled once, when the imported names are recorded, and calls to a declared callable by that callable's identity, so completing again reads the decision back. The planner binds each reference under the spelling it carries. A build's merged wrapper module now owns copies of its source modules. Completing it for the build mutated the very modules the build's contract package was written from, so the contract read spellings completed for a different namespace. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 12 ++ docs/developer/packages/policy.md | 6 +- prik/pipeline/build.py | 5 + prik/policy/contract_imports.py | 87 ++++++------ prik/policy/exports.py | 125 ++++++++++++++++-- prik/utilities/declaration_expressions.py | 71 ++++++++++ .../test_declaration_extent_expressions.py | 57 ++++++++ .../test_declaration_expression_utilities.py | 18 +++ .../test_pyi_printer_imports_and_packages.py | 56 ++++++++ 9 files changed, 388 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f3ea9e46..be3d75057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ release tags add a leading `v` to the package version. ## Unreleased +- A call in a declaration expression is spelled the way the contract binds its + callee. A Fortran specification function named `lambda`, or one whose name + collides once escaped, was imported under its completed name (`lambda_`, + `lambda__2`) while the shape still called `lambda(n)`, which is not Python; + such a function could not size an array at all, from source or from its + contract. Contract-name completion now sets the callable reference's name to + that spelling and respells the call in the shape expression through the + parsed expression, changing call targets only, while the native identity + still reaches the Fortran function. A build's merged wrapper module now owns + copies of its source modules, so completing it no longer changes the + contracts written beside the build. + - A generated contract imports what it needs to bind, not the `use` statements its source wrote. A facade extending a generic it `use`s from two modules imported `convert` from each of them beside declaring the merged diff --git a/docs/developer/packages/policy.md b/docs/developer/packages/policy.md index 71f5abe46..8415ed353 100644 --- a/docs/developer/packages/policy.md +++ b/docs/developer/packages/policy.md @@ -171,7 +171,11 @@ read the decision without importing policy; class-surface construction reads it the same way. A name the module imports is completed in the same ledger, as the module publishes it or, for a type it does not publish, as a class, and recorded under `CONTRACT_IMPORT_NAMES_METADATA`; its annotations, its import, -and `__all__` all read that one spelling. +and `__all__` all read that one spelling. A callable a declaration expression +calls is spelled the same way: completion sets `SemanticExpressionCallable.name` +to the contract spelling and respells that call in the shape expression through +the parsed expression, so only call targets change; `native_name` and +`native_scope` keep the native identity. `complete_contract_imports()` runs once names are complete, over the modules written together. A contract binds what its declarations name and what it diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index ebf1b03b3..be4e8133f 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -3177,9 +3177,14 @@ def _merge_wrapper_modules(modules: list[SemanticModule], *, name: str | None = Concatenates every declaration category while preserving list order and derives combined metadata and the origin from the first module. An empty input cannot produce a wrapper and raises ``ValueError``. + + The merged module is completed for this build as one namespace, which is + not how each source module's own contract is completed, so it owns copies: + completing it leaves the source modules describing their own contracts. """ if not modules: raise ValueError("wrapper build found no Fortran modules or standalone procedures") + modules = deepcopy(modules) return SemanticModule( name=name or modules[0].name, diff --git a/prik/policy/contract_imports.py b/prik/policy/contract_imports.py index 844a7c6e6..bd5581b90 100644 --- a/prik/policy/contract_imports.py +++ b/prik/policy/contract_imports.py @@ -18,7 +18,13 @@ from collections.abc import Iterable, Iterator from prik.naming import normalize_public_name, preserves_source_case -from prik.policy.exports import contract_name_for_source, contract_names_by_source, imported_type_reference +from prik.policy.exports import ( + contract_name_for_source, + contract_names_by_source, + declaration_identity, + declared_identities, + imported_type_reference, +) from prik.semantics import models from prik.semantics.pyi_metadata import PYI_LOADED_METADATA @@ -59,15 +65,15 @@ def __init__(self, module: models.SemanticModule, completed: dict[str, dict[str, self._key = str if self._preserve_case else str.casefold # The one spelling naming completed for each name this contract imports. self._imported = module.metadata.get(models.CONTRACT_IMPORT_NAMES_METADATA, {}) if self._native else {} - declarations = (*module.functions, *module.classes, *module.variables, *module.prototypes) - self._declared_names = {self._key(str(item.name)) for item in (*declarations, *module.overload_sets)} - self._declared = { - *( - _identity(item.origin.native_scope or module.name, getattr(item, "native_name", None) or item.name) - for item in declarations - ), - *(_identity(item.native_scope or module.name, item.name) for item in module.overload_sets), - } + declarations = ( + *module.functions, + *module.classes, + *module.variables, + *module.prototypes, + *module.overload_sets, + ) + self._declared_names = {self._key(models.completed_contract_name(item)) for item in declarations} + self._declared = declared_identities(module) self._bound: dict[str, tuple[str, str]] = {} self._statements: list[str | models.SemanticImport] = [] self._from: dict[str, models.SemanticImport] = {} @@ -91,8 +97,8 @@ def bindings(self) -> list[str | models.SemanticImport]: str(reexport.local_name), verbatim=reexport.entity_kind == "prototype", ) - for origin, source, local, kind in sorted(set(self._references())): - self._bind(origin, source, local, verbatim=kind in {"prototype", "namespace"}) + for origin, source, local, written, kind in sorted(set(self._references())): + self._bind(origin, source, local, written=written, verbatim=kind in {"prototype", "namespace"}) return self._statements def _stated(self, statement: str | models.SemanticImport) -> None: @@ -103,25 +109,37 @@ def _stated(self, statement: str | models.SemanticImport) -> None: for item in statement.items: self._bind(statement.module, item.source, item.target or item.source) - def _references(self) -> Iterator[tuple[str, str, str, str]]: - """Yield ``(module, source, local, kind)`` for each name a declaration names.""" + def _references(self) -> Iterator[tuple[str, str, str, str, str]]: + """Yield ``(module, source, local, written, kind)`` for each name a declaration names. + + ``written`` is the spelling the declaration writes, which completion + recorded on the reference itself. + """ for semantic_type in models._module_semantic_types(self._module): yield from _type_reference(semantic_type) yield from _prototype_reference(semantic_type.metadata.get(models.PROTOTYPE_REF_METADATA)) yield from _callable_references(semantic_type) - def _bind(self, origin: str, source: str, local: str, *, verbatim: bool = False) -> None: + def _bind( + self, + origin: str, + source: str, + local: str, + *, + written: str | None = None, + verbatim: bool = False, + ) -> None: """Bind ``local`` to ``source`` read from ``origin``, or refuse a second meaning. - The contract binds the spelling naming completed for ``local``, which is - the one its annotations and ``__all__`` write. A name completion did not - spell -- a prototype, or a callable a declaration expression writes -- - keeps the spelling it is written with. + The contract binds the name as it writes it: ``written`` for a + reference completion already spelled, and otherwise the spelling + completion recorded for ``local``, which ``__all__`` writes too. """ origin_key = origin.lstrip(".").casefold() - if origin_key == self._module.name.casefold() or _identity(origin_key, source) in self._declared: + if origin_key == self._module.name.casefold() or declaration_identity(origin_key, source) in self._declared: return - key = self._key(local) + contract_target = written or contract_name_for_source(self._imported, local) or local + key = self._key(contract_target) identity = (origin_key, self._key(source)) existing = self._bound.get(key) if existing == identity: @@ -132,13 +150,12 @@ def _bind(self, origin: str, source: str, local: str, *, verbatim: bool = False) "the name already means something else there" ) self._bound[key] = identity - written = f".{origin}" if self._native and not origin.startswith(".") else origin - statement = self._from.get(written) + module_text = f".{origin}" if self._native and not origin.startswith(".") else origin + statement = self._from.get(module_text) if statement is None: - statement = self._from[written] = models.SemanticImport(module=written) + statement = self._from[module_text] = models.SemanticImport(module=module_text) self._statements.append(statement) contract_source = self._contract_source(origin_key, source, verbatim) - contract_target = contract_name_for_source(self._imported, local) or local statement.items.append( models.SemanticImportItem( source=source, @@ -164,38 +181,34 @@ def _contract_source(self, origin_key: str, source: str, verbatim: bool) -> str: return source if verbatim else normalize_public_name(source, preserve_case=self._preserve_case).name -def _identity(scope: str, name: str) -> tuple[str, str]: - """Return the case-folded ``(module, name)`` identity of one declaration.""" - return str(scope).casefold(), str(name).casefold() - - -def _type_reference(semantic_type: models.SemanticType) -> Iterator[tuple[str, str, str, str]]: +def _type_reference(semantic_type: models.SemanticType) -> Iterator[tuple[str, str, str, str, str]]: """Yield the binding one annotation naming an imported type needs.""" reference = imported_type_reference(semantic_type) if reference is None: return if reference.procedure_local: # A procedure-local type is written qualified by its module. - yield ".", reference.module, reference.module, "namespace" + yield ".", reference.module, reference.module, reference.module, "namespace" else: - yield reference.module, reference.name, reference.local, "type" + written = str(semantic_type.metadata.get(models.CONTRACT_NAME_METADATA) or reference.local) + yield reference.module, reference.name, reference.local, written, "type" -def _prototype_reference(ref: object) -> Iterator[tuple[str, str, str, str]]: +def _prototype_reference(ref: object) -> Iterator[tuple[str, str, str, str, str]]: """Yield the binding one callback annotation naming a prototype needs.""" if not isinstance(ref, dict): return origin = str(ref.get("origin_module") or "") local = str(ref.get("local_name") or ref.get("name") or "") if origin and local: - yield origin, str(ref.get("name") or local), local, "prototype" + yield origin, str(ref.get("name") or local), local, local, "prototype" -def _callable_references(semantic_type: models.SemanticType) -> Iterator[tuple[str, str, str, str]]: +def _callable_references(semantic_type: models.SemanticType) -> Iterator[tuple[str, str, str, str, str]]: """Yield the binding each callable a declaration expression calls needs.""" array = semantic_type.storage.array if semantic_type.storage is not None else None for axis in array.expression_callables if array is not None else (): for reference in axis: if reference.native_scope is not None: local = reference.name.rsplit(".", 1)[-1] - yield reference.native_scope, reference.native_name or local, local, "procedure" + yield reference.native_scope, reference.native_name or local, local, local, "procedure" diff --git a/prik/policy/exports.py b/prik/policy/exports.py index 5e42817da..db0ca8a69 100644 --- a/prik/policy/exports.py +++ b/prik/policy/exports.py @@ -13,6 +13,7 @@ class members still need a contract identity even when they publish nothing. from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import NamedTuple @@ -20,6 +21,7 @@ class members still need a contract identity even when they publish nothing. from prik.semantics import models from prik.semantics.pyi_metadata import PYI_LOADED_METADATA from prik.semantics.models import export_namespace +from prik.utilities.declaration_expressions import rename_declaration_expression_calls @dataclass(frozen=True) @@ -333,6 +335,7 @@ def _complete_contract_names( ) _complete_type_reference_names(module, imported) + _complete_declared_callable_names(module, contract_named=contract_named) _complete_overload_target_contract_names(module, preserve_case=preserve_case) @@ -432,24 +435,124 @@ def _complete_imported_names( A re-export is already named: the name the module publishes it under is the name the contract binds and writes. A type the module imports without - re-exporting it takes the class spelling a published type would, held - beside the module's own names. A contract that was read already names what - it imports and keeps every spelling. + re-exporting it takes the class spelling a published type would, and a + callable a declaration expression calls the spelling a function would, each + held beside the module's own names. A contract that was read already names + what it imports and keeps every spelling. + + The record is read back when completion runs again: by then the calls it + spelled carry their completed names, which are not names to import. """ + recorded = module.metadata.get(models.CONTRACT_IMPORT_NAMES_METADATA) + if recorded is not None: + return recorded completed = {str(reexport.local_name): str(reexport.python_name) for reexport in module.reexports} if not contract_named: - for semantic_type in models._module_semantic_types(module): - reference = imported_type_reference(semantic_type) - if reference is None or reference.procedure_local: - continue - if contract_name_for_source(completed, reference.local) is None: - completed[reference.local] = naming.reserve_public_name( - (), reference.local, category="class", owner=f"import {reference.local}" - ) + declared = declared_identities(module) + for local, category in _imported_local_names(module, declared): + if contract_name_for_source(completed, local) is None: + completed[local] = naming.reserve_public_name((), local, category=category, owner=f"import {local}") + + def imported_spelling(reference: models.SemanticExpressionCallable) -> str | None: + identity = _callable_identity(reference) + if identity is None or identity in declared: + return None + return contract_name_for_source(completed, reference.name) + + # A call to an imported callable is spelled now, once: afterwards its + # reference carries the completed name, which is not a name it imports. + _respell_expression_calls(module, imported_spelling) module.metadata[models.CONTRACT_IMPORT_NAMES_METADATA] = completed return completed +def _imported_local_names(module: models.SemanticModule, declared: set[tuple[str, str]]): + """Yield ``(local name, category)`` for each name a declaration reads from another module.""" + for semantic_type in models._module_semantic_types(module): + reference = imported_type_reference(semantic_type) + if reference is not None and not reference.procedure_local: + yield reference.local, "class" + for callable_reference in _expression_callables(semantic_type): + identity = _callable_identity(callable_reference) + if identity is not None and identity not in declared: + yield callable_reference.name, "function" + + +def _complete_declared_callable_names(module: models.SemanticModule, *, contract_named: bool) -> None: + """Spell each call to a callable the module declares under that callable's contract name. + + The reference and the call in the public shape change together, so the + expression and the declaration it calls agree; the native identity stays + beside them. Imported calls were spelled with the names the module imports + them by, and every name a read contract writes is kept. + """ + if contract_named: + return + declared = { + declaration_identity(item.origin.native_scope or module.name, item.native_name or item.name): ( + models.completed_contract_name(item) + ) + for item in (*module.functions, *module.prototypes) + } + _respell_expression_calls(module, lambda reference: declared.get(_callable_identity(reference))) + + +def _respell_expression_calls( + module: models.SemanticModule, + spelling: Callable[[models.SemanticExpressionCallable], str | None], +) -> None: + """Give each call a declaration expression makes the spelling ``spelling`` returns. + + A reference and its call sites change together; ``None`` keeps a call as it + is written. Only call targets change in the expression text. + """ + for semantic_type in models._module_semantic_types(module): + array = semantic_type.storage.array if semantic_type.storage is not None else None + for axis, references in enumerate(array.expression_callables if array is not None else ()): + names: dict[str, str] = {} + for reference in references: + completed = spelling(reference) + if completed is not None and completed != reference.name: + names[reference.name] = completed + reference.name = completed + if names: + for shape in (semantic_type.shape, array.shape): + if axis < len(shape): + shape[axis] = rename_declaration_expression_calls(str(shape[axis]), names) + + +def _expression_callables(semantic_type: models.SemanticType): + """Yield every callable one type's declaration expressions call.""" + array = semantic_type.storage.array if semantic_type.storage is not None else None + for references in array.expression_callables if array is not None else (): + yield from references + + +def _callable_identity(reference: models.SemanticExpressionCallable) -> tuple[str, str] | None: + """Return the declaration one call reaches, or ``None`` for a call with no module.""" + if reference.native_scope is None: + return None + return declaration_identity(reference.native_scope, reference.native_name or reference.name.rsplit(".", 1)[-1]) + + +def declared_identities(module: models.SemanticModule) -> set[tuple[str, str]]: + """Return the ``(module, name)`` identity of every declaration the module carries.""" + return { + *( + declaration_identity( + item.origin.native_scope or module.name, getattr(item, "native_name", None) or item.name + ) + for item in (*module.functions, *module.classes, *module.variables, *module.prototypes) + ), + *(declaration_identity(item.native_scope or module.name, item.name) for item in module.overload_sets), + } + + +def declaration_identity(scope: object, name: object) -> tuple[str, str]: + """Return the case-folded ``(module, name)`` identity of one declaration.""" + return str(scope).casefold(), str(name).casefold() + + def _complete_type_reference_names(module: models.SemanticModule, imported: dict[str, str]) -> None: """Spell every type a declaration names the way the contract binds it. diff --git a/prik/utilities/declaration_expressions.py b/prik/utilities/declaration_expressions.py index 5ba15584e..8f6fbd64f 100644 --- a/prik/utilities/declaration_expressions.py +++ b/prik/utilities/declaration_expressions.py @@ -16,7 +16,10 @@ from __future__ import annotations import ast +import io import re +import tokenize +from keyword import iskeyword from collections.abc import Callable, Mapping from dataclasses import dataclass @@ -678,11 +681,79 @@ def _parse_expression(expression: str) -> ast.Expression | None: returns an ``eval``-mode tree. It returns ``None`` only for syntax that the public caller must preserve, block, or reframe with its own diagnostic; it never modifies the supplied text. + + A native name may be one Python reserves -- a Fortran function can be + called ``lambda`` -- and it is still a name. Such a name is set aside while + Python parses the rest and restored in the tree, so the call is read as a + call rather than the whole expression as invalid. """ try: return ast.parse(expression, mode="eval") except SyntaxError: + pass + escaped = _escape_reserved_names(expression) + if escaped is None: + return None + try: + tree = ast.parse(escaped, mode="eval") + except SyntaxError: + return None + for node in ast.walk(tree): + if isinstance(node, ast.Name) and node.id.startswith(_RESERVED_NAME_ESCAPE): + node.id = node.id.removeprefix(_RESERVED_NAME_ESCAPE) + elif isinstance(node, ast.Attribute) and node.attr.startswith(_RESERVED_NAME_ESCAPE): + node.attr = node.attr.removeprefix(_RESERVED_NAME_ESCAPE) + return tree + + +#: Keywords the lexical translation writes itself; any other one is a native name. +_TRANSLATED_KEYWORDS = frozenset({"and", "or", "not", "True", "False"}) +_RESERVED_NAME_ESCAPE = "_prik_reserved_" + + +def _escape_reserved_names(expression: str) -> str | None: + """Return the text with each reserved native name escaped, or ``None``. + + Python's own tokenizer finds the names, so a literal or an operator is + never mistaken for one. ``None`` means no name needed escaping, or the text + does not tokenize. + """ + try: + tokens = list(tokenize.generate_tokens(io.StringIO(expression).readline)) + except (tokenize.TokenError, SyntaxError): return None + reserved = [ + token.type == tokenize.NAME and iskeyword(token.string) and token.string not in _TRANSLATED_KEYWORDS + for token in tokens + ] + if not any(reserved): + return None + return tokenize.untokenize( + (token.type, _RESERVED_NAME_ESCAPE + token.string if escape else token.string) + for token, escape in zip(tokens, reserved, strict=True) + ) + + +def rename_declaration_expression_calls(expression: str, names: Mapping[str, str]) -> str: + """Return one expression with its call targets respelled, and nothing else. + + ``names`` maps a call target as the expression writes it to the spelling + that replaces it. Only a called name changes: an argument, a variable, an + attribute, or a literal spelled the same way is left alone. An expression + with nothing to respell, or one that does not parse, is returned unchanged. + """ + tree = _parse_expression(expression) + if tree is None: + return expression + changed = False + for node in ast.walk(tree): + if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)): + continue + spelled = names.get(node.func.id, node.func.id) + if spelled != node.func.id: + node.func.id = spelled + changed = True + return ast.unparse(tree) if changed else expression def _python_parseable_fortran_expression(expression: str) -> str: diff --git a/tests/fortran/arrays/end_to_end/test_declaration_extent_expressions.py b/tests/fortran/arrays/end_to_end/test_declaration_extent_expressions.py index 3a8236c63..955aebf27 100644 --- a/tests/fortran/arrays/end_to_end/test_declaration_extent_expressions.py +++ b/tests/fortran/arrays/end_to_end/test_declaration_extent_expressions.py @@ -369,3 +369,60 @@ def test_prototype_entity_is_visible_inside_a_standalone_target_interface(tmp_pa assert f"procedure({interface_symbol}) :: external_extent" in bridge assert "import :: c_int32_t, external_extent, c_double" in bridge assert "real(c_double), dimension(external_extent(n)) :: native_result" in bridge + + +RESERVED_EXTENT_PROVIDER = """ +module reserved_extent_provider + implicit none +contains + pure integer function lambda(n) + integer, intent(in) :: n + lambda = n + end function lambda + pure integer function lambda_(n) + integer, intent(in) :: n + lambda_ = n + 1 + end function lambda_ +end module reserved_extent_provider +""" + + +RESERVED_EXTENT_OWNER = """ +module reserved_extent_owner + use, intrinsic :: iso_c_binding, only: c_double + use reserved_extent_provider, only: lambda, lambda_ + implicit none +contains + function keyword_values(n) result(output) + integer, intent(in) :: n + real(c_double) :: output(lambda(n)) + output = 1.0_c_double + end function keyword_values + function collided_values(n) result(output) + integer, intent(in) :: n + real(c_double) :: output(2*lambda_(n) + n) + output = 2.0_c_double + end function collided_values +end module reserved_extent_owner +""" + + +def test_specification_functions_python_must_rename_still_size_their_results(tmp_path: Path): + """`lambda` is a Python keyword and `lambda_` then collides with its escape. + + The contract calls them `lambda_` and `lambda__2`, while the native calls + still reach the Fortran functions spelled `lambda` and `lambda_`. + """ + module, _payload = _build_sources_and_import( + [ + ("reserved_extent_provider.f90", RESERVED_EXTENT_PROVIDER), + ("reserved_extent_owner.f90", RESERVED_EXTENT_OWNER), + ], + tmp_path, + ) + + np.testing.assert_array_equal(module.reserved_extent_owner.keyword_values(np.int32(3)), np.full(3, 1.0)) + np.testing.assert_array_equal(module.reserved_extent_owner.collided_values(np.int32(3)), np.full(11, 2.0)) + contract = (tmp_path / "contracts" / "reserved_extent_owner.pyi").read_text(encoding="utf-8") + assert "-> Float64[lambda_(n)]" in contract + assert "-> Float64[2 * lambda__2(n) + n]" in contract diff --git a/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py b/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py index 3d4c2228c..c04dc7efe 100644 --- a/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py +++ b/tests/fortran/arrays/semantics/test_declaration_expression_utilities.py @@ -19,6 +19,7 @@ fortran_extent_to_python, is_declaration_expression_helper, is_public_declaration_expression, + rename_declaration_expression_calls, render_declaration_extent, resolve_declaration_extent, split_declaration_assignment, @@ -365,3 +366,20 @@ def test_lexical_translation_leaves_character_literals_alone(): # Everything outside the literal is still translated. assert _python_parseable_fortran_expression('obj%field + len("a%b")') == 'obj.field + len("a%b")' assert _python_parseable_fortran_expression(".true.") == "True" + + +def test_a_native_name_python_reserves_is_still_read_as_a_call(): + """A Fortran function may be called `lambda`; the call is not invalid syntax.""" + assert declaration_expression_calls("lambda(n) + class(2)") == ("lambda", "class") + assert declaration_expression_identifiers("lambda(n) + 1") == ("lambda", "n") + + +def test_respelling_changes_call_targets_and_nothing_else(): + """A variable or a literal spelled like the callee keeps its spelling.""" + assert rename_declaration_expression_calls("lambda(n)", {"lambda": "lambda_"}) == "lambda_(n)" + assert ( + rename_declaration_expression_calls("helper(n) + helper + len('helper(')", {"helper": "helper_2"}) + == "helper_2(n) + helper + len('helper(')" + ) + # Nothing to respell leaves the text exactly as written. + assert rename_declaration_expression_calls("2*n", {"helper": "helper_2"}) == "2*n" 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 0e4db70b4..29477554a 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 @@ -1091,3 +1091,59 @@ def test_an_imported_type_is_spelled_one_way_throughout_its_contract(association assert f"p: {spelling}\n" in contract expected_all = ["move", spelling] if published else ["move"] assert contract.rstrip().endswith(f"__all__ = {json.dumps(expected_all)}") + + +def test_a_declaration_expression_calls_its_callee_by_the_name_the_contract_binds(tmp_path): + """The call in a shape and the import binding its callee are one spelling. + + `lambda` is a Python keyword and `lambda_` then collides with its escaped + spelling, so the helpers contract writes `lambda_` and `lambda__2`. The + expression kept the Fortran spelling while the import bound the completed + one, leaving a call that was unbound or not Python at all. + """ + helpers = parse_fortran_source(""" +module helpers +implicit none +contains +pure integer function lambda(n) + integer, intent(in) :: n + lambda = n +end function lambda +pure integer function lambda_(n) + integer, intent(in) :: n + lambda_ = n + 1 +end function lambda_ +end module helpers +""") + user = parse_fortran_source(""" +module user_mod +use helpers, only : lambda, lambda_ +implicit none +contains +subroutine fill(n, x, y) + integer, intent(in) :: n + real(8), intent(out) :: x(lambda(n)) + real(8), intent(out) :: y(2*lambda_(n) + n) +end subroutine fill +end module user_mod +""") + + stubs = emit_module_stubs( + [fortran_module_to_semantic_module(item) for item in (helpers, user)], + normalize_public_names=True, + ) + contract = stubs["user_mod"] + + assert "from .helpers import lambda_, lambda__2\n" in contract + assert "x: Float64[lambda_(n)]" in contract + assert "y: Float64[2 * lambda__2(n) + n]" in contract + # Read back as a package, each call reaches the native function it names. + for name, text in stubs.items(): + (tmp_path / f"{name}.pyi").write_text(text, encoding="utf-8") + reloaded = {module.name: module for module in pyi_pipeline.pyi_paths_to_semantic_modules(tmp_path)} + assert [ + (reference.name, reference.native_scope, reference.native_name) + for argument in reloaded["user_mod"].functions[0].arguments[1:] + for axis in argument.semantic_type.storage.array.expression_callables + for reference in axis + ] == [("lambda_", "helpers", "lambda"), ("lambda__2", "helpers", "lambda_")] From 62e9d754afbd648abbb01c5b5ad781150c1969ae Mon Sep 17 00:00:00 2001 From: said Date: Sat, 19 Sep 2026 08:23:22 +0100 Subject: [PATCH 80/96] Show the generated contract from the parser CLI's --pyi The parser CLI converted each module alone and printed it without completion or import planning, a second .pyi route beside the contract pipeline. It now converts every inspected module together and emits them through emit_module_stubs(), so --pyi shows the contract `prik generate --pyi` writes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 4 ++ docs/developer/packages/parsers.md | 2 +- prik/parsers/fortran/cli.py | 44 ++++++++++++------- .../cli/pipeline/test_output_contract.py | 35 +++++++++++++++ 4 files changed, 69 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be3d75057..f986448e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ release tags add a leading `v` to the package version. copies of its source modules, so completing it no longer changes the contracts written beside the build. +- The Fortran parser CLI's `--pyi` report emits every inspected module together + through the contract pipeline, so it shows the contract `prik generate --pyi` + writes rather than an unplanned rendering of each module alone. + - A generated contract imports what it needs to bind, not the `use` statements its source wrote. A facade extending a generic it `use`s from two modules imported `convert` from each of them beside declaring the merged diff --git a/docs/developer/packages/parsers.md b/docs/developer/packages/parsers.md index d2c5ec6dd..a55f7afe1 100644 --- a/docs/developer/packages/parsers.md +++ b/docs/developer/packages/parsers.md @@ -89,7 +89,7 @@ prik/parsers/ | [`prik/parsers/fortran/scope.py`](../../../prik/parsers/fortran/scope.py) | `ScopeUses` aggregates a scope's `use` statements and is the authority for rename semantics, accessible local names, and candidate routes. Semantic consumers decide only what those routes mean for their entity category. | `use` association or scope dependency interpretation changes. | | [`prik/parsers/fortran/type_resolver.py`](../../../prik/parsers/fortran/type_resolver.py) | `extract_kind_from_type_spec()` preserves intrinsic kind and character syntax after declaration parsing. | Parser-level type-spec spelling extraction changes. | | [`prik/parsers/fortran/parser.py`](../../../prik/parsers/fortran/parser.py) | `FortranParser`, `parse_fortran_file()`, and `parse_fortran_project()` build file and project models. | Grammar, source-unit structure, declarations, parser diagnostics, or project assembly changes. | -| [`prik/parsers/fortran/cli.py`](../../../prik/parsers/fortran/cli.py) | `main()` formats parser reports and diagnostics. Its `--semantics` and `--pyi` options explicitly invoke later stages. | Parser CLI arguments, report layout, or diagnostic presentation changes. | +| [`prik/parsers/fortran/cli.py`](../../../prik/parsers/fortran/cli.py) | `main()` formats parser reports and diagnostics. Its `--semantics` and `--pyi` options explicitly invoke later stages; `--pyi` emits every inspected module through `emit_module_stubs()`, so it shows the contract `prik generate --pyi` writes. | Parser CLI arguments, report layout, or diagnostic presentation changes. | | [`prik/parsers/c/`](../../../prik/parsers/c/README.md) | `parse_c_file()` and `parse_c_project()` build `CFile`/`CProject` records; the local lexer, models, resolver, and CLI preserve C declarations, project facts, diagnostics, and report output. | C tokenization, declarations, type resolution, project assembly, or parser reports change. | | [`prik/parsers/pyi/__init__.py`](../../../prik/parsers/pyi/__init__.py) | Re-exports `parse_pyi_text()` and `parse_pyi_file()`. | The supported raw-`.pyi` parser import surface changes. | | [`prik/parsers/pyi/parser.py`](../../../prik/parsers/pyi/parser.py) | Parses text or a file into `ast.Module` with no contract interpretation. | Raw Python syntax input, file reading, or parse diagnostics change. | diff --git a/prik/parsers/fortran/cli.py b/prik/parsers/fortran/cli.py index 2b8c261f4..cff43eabc 100644 --- a/prik/parsers/fortran/cli.py +++ b/prik/parsers/fortran/cli.py @@ -89,24 +89,38 @@ def _parse_paths(paths: list[str]) -> dict[str, dict]: def _semantic_report(paths: list[str]) -> dict[str, dict]: - """Generate semantic IR and pyi text per parsed file.""" - from prik.semantics.fortran2ir import fortran_module_to_semantic_module - from prik.printers import emit_module + """Generate semantic IR and the generated .pyi per parsed file. - parsed = _parse_paths(paths) - semantic_out: dict[str, dict] = {} - parser = FortranParser() + Every module read is converted together and its contract emitted the way + ``prik generate --pyi`` emits it, so an import names what the module it + reads from declares and the report shows the contract a build would use. + """ + from prik.parsers.fortran.models import FortranProject + from prik.pipeline.pyi import emit_module_stubs + from prik.semantics.fortran2ir import fortran_project_to_semantic_modules - for fname in parsed: - code = Path(fname).read_text(encoding="utf-8") - fobj = parser.parse_file(code, filename=fname) - modules = [fortran_module_to_semantic_module(m) for m in fobj.modules] - semantic_out[fname] = { - "semantic_modules": [asdict(m) for m in modules], - "pyi": "\n\n".join(emit_module(m) for m in modules).strip(), + parser = FortranParser() + files = { + fname: parser.parse_file(Path(fname).read_text(encoding="utf-8"), filename=fname) + for fname in _parse_paths(paths) + } + converted = { + module.name.casefold(): module + for module in fortran_project_to_semantic_modules(FortranProject(files=list(files.values()))) + } + modules_by_file = { + fname: [converted[module.name.casefold()] for module in parsed.modules if module.name.casefold() in converted] + for fname, parsed in files.items() + } + modules = [module for file_modules in modules_by_file.values() for module in file_modules] + stubs = emit_module_stubs(modules, normalize_public_names=True) if modules else {} + return { + fname: { + "semantic_modules": [asdict(module) for module in file_modules], + "pyi": "\n\n".join(stubs[module.name] for module in file_modules).strip(), } - - return semantic_out + for fname, file_modules in modules_by_file.items() + } def _format_pyi_report(semantic_report: dict[str, dict]) -> str: diff --git a/tests/fortran/infrastructure/cli/pipeline/test_output_contract.py b/tests/fortran/infrastructure/cli/pipeline/test_output_contract.py index 1a1d3acf2..c5c793a9f 100644 --- a/tests/fortran/infrastructure/cli/pipeline/test_output_contract.py +++ b/tests/fortran/infrastructure/cli/pipeline/test_output_contract.py @@ -1026,3 +1026,38 @@ def test_assume_intent_in_scalars_removes_them_from_the_generated_contract(tmp_p assert "Returns" not in text assert "-> Float64: ..." in text + + +def test_fortran_parser_cli_pyi_is_the_contract_generate_writes(tmp_path: Path): + """The parser CLI shows the generated contract, not an unplanned rendering of its own. + + Its report converted and printed each module alone, so a module importing + from another file lost the import completion plans and the spelling + completion gives each name. + """ + helpers = tmp_path / "helpers.f90" + helpers.write_text( + "module helpers\ncontains\n" + "pure integer function lambda(n)\ninteger, intent(in) :: n\nlambda = n\nend function lambda\n" + "end module helpers\n", + encoding="utf-8", + ) + user = tmp_path / "user.f90" + user.write_text( + "module user_mod\nuse helpers, only : lambda\ncontains\n" + "subroutine fill(n, x)\ninteger, intent(in) :: n\nreal(8), intent(out) :: x(lambda(n))\nend subroutine fill\n" + "end module user_mod\n", + encoding="utf-8", + ) + contracts = tmp_path / "contracts" + subprocess.run( + [sys.executable, "-m", "prik", "generate", "--pyi", str(helpers), str(user), "--out", str(contracts)], + check=True, + capture_output=True, + ) + + report = fortran_parser_cli._semantic_report([str(helpers), str(user)]) + + assert report[str(helpers)]["pyi"] == (contracts / "helpers.pyi").read_text(encoding="utf-8").strip() + assert report[str(user)]["pyi"] == (contracts / "user_mod.pyi").read_text(encoding="utf-8").strip() + assert "from .helpers import lambda_" in report[str(user)]["pyi"] From 7f4cdae31146344d3f1da70eede4ff20abef5a52 Mon Sep 17 00:00:00 2001 From: said Date: Sat, 19 Sep 2026 09:37:02 +0100 Subject: [PATCH 81/96] State @pure on a pure module function in its contract A declaration expression may call only a pure function, and policy checks the declaration it calls. A Fortran source records that purity, but a contract could state it only on a prototype -- @pure elsewhere was rejected and the printer never wrote it -- so any generated contract whose array extents call an imported function read that function back impure and failed to build with "must be pure", while the source it came from built. The loader now accepts @pure on a module-level function and records it the way Fortran source does, and the printer writes it for a module function whose Fortran attributes are pure. A method and an @overload dispatcher still reject it: neither names the native procedure being described. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 6 ++++ docs/user/reference/pyi-format.md | 14 ++++---- prik/printers/pyi.py | 6 ++++ prik/semantics/pyi2ir.py | 12 ++++--- .../arrays/semantics/test_array_semantics.py | 32 +++++++++++++++++++ .../semantics/test_pyi_callback_semantics.py | 16 +++++++++- 6 files changed, 75 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f986448e1..5261a2c6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ release tags add a leading `v` to the package version. ## Unreleased +- A generated contract states `@pure` on a pure module function, and a + contract may write it there. A declaration expression may call only a pure + function, and `@pure` was accepted only on prototypes, so any generated + contract whose array extents call an imported function failed to build with + "must be pure" while the source it came from built. + - A call in a declaration expression is spelled the way the contract binds its callee. A Fortran specification function named `lambda`, or one whose name collides once escaped, was imported under its completed name (`lambda_`, diff --git a/docs/user/reference/pyi-format.md b/docs/user/reference/pyi-format.md index 75f5aadd7..24d2679e3 100644 --- a/docs/user/reference/pyi-format.md +++ b/docs/user/reference/pyi-format.md @@ -558,7 +558,7 @@ Python declaration and native callable names differ. | `@native_call([...], result=...)` | Function, method, or constructor | Shared: state the complete native argument order and optional native result mapping. | | `@overload("specific", generic=...)` | Function or method | Shared: add one exact candidate to a generated Python overload set. | | `@prototype` | Module-level function declaration | Fortran exact procedure interface used by callbacks or declaration expressions. | -| `@pure` | `@prototype` declaration | Fortran: preserve the native pure characteristic. | +| `@pure` | Module-level function or `@prototype` declaration | Fortran: preserve the native pure characteristic. | | `@raises(status=..., message=..., success=...)` | Function or method | Shared: consume named status outputs and raise on non-success. | | `@nogil` | Function or method | Shared: request GIL release around the completed native call. | | `@abstractmethod` | Method | Fortran deferred binding. | @@ -566,8 +566,8 @@ Python declaration and native callable names differ. | `@staticmethod` | Method | Python stub marker for a method without `self`. | Decorators are validated in context. `@prototype` cannot combine with wrapper -decorators, `@overload` cannot combine with `@native_call`, and `@pure` requires -`@prototype`. +decorators, `@overload` cannot combine with `@native_call`, and `@pure` applies +to a module-level native procedure, not a method or an `@overload` dispatcher. A status projection can hide its consumed output from the Python return: @@ -628,8 +628,10 @@ def update_values( def apply_update(callback: update_values) -> None: ... ``` -`@pure` is valid only with `@prototype`. Calling a pure prototype name inside a -declaration expression identifies a standalone specification function. Current +`@pure` states that a native procedure is pure, which a function called in a +declaration expression must be: a module function the expression imports +carries it, and calling a pure prototype name identifies a standalone +specification function. Current callback wrapper support is Fortran-specific; C function pointers can be inspected but are not buildable C callbacks. @@ -1130,7 +1132,7 @@ The loader rejects malformed language forms before wrapper planning: - Python enum classes instead of `Final[...]` integer constants; - `typing.overload` instead of PRIK `@overload("specific")`; - `@overload` combined with `@native_call`; -- `@pure` without `@prototype`; +- `@pure` on a method or an `@overload` dispatcher; - `@native_abi(...)` outside Fortran or with a value other than `"c"`; - incomplete, duplicated, or out-of-range `@native_call` entries; - untyped hidden literals inside `@native_call`; diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index 46c4acf42..f390a426d 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -1927,6 +1927,12 @@ def _decorators( and not func.metadata.get(OVERLOAD_TARGET_METADATA) ): decorators.append(f"{indent}@{context.contract('standalone')}") + if ( + not isinstance(func, SemanticMethod) + and not func.metadata.get(OVERLOAD_TARGET_METADATA) + and any(str(attribute).casefold() == "pure" for attribute in func.metadata.get("fortran_attributes", ())) + ): + decorators.append(f"{indent}@{context.contract('pure')}") if not func.metadata.get(OVERLOAD_TARGET_METADATA) and self._requires_native_call(func): decorators.append( f"{indent}{self._native_call(self._pyi_projection(func), context, self._native_result_projection(func), func)}" diff --git a/prik/semantics/pyi2ir.py b/prik/semantics/pyi2ir.py index 5af739324..4502cd3af 100644 --- a/prik/semantics/pyi2ir.py +++ b/prik/semantics/pyi2ir.py @@ -862,8 +862,8 @@ def decorators(self, nodes: list[ast.expr], *, context: str) -> _Decorators: raise ValueError("destroy can only be combined with bind") if parsed.overload_target is not None and parsed.has_native_call: raise ValueError("overload cannot be combined with native_call; put native_call on the specific procedure") - if parsed.pure and not parsed.prototype: - raise ValueError("pure requires prototype") + if parsed.pure and parsed.overload_target is not None: + raise ValueError("pure describes a native procedure; an overload dispatcher names none") if parsed.prototype: if parsed.standalone: raise ValueError( @@ -966,11 +966,11 @@ def _apply_prototype_decorator(parsed: _Decorators, node: ast.expr, context: str @staticmethod def _apply_pure_decorator(parsed: _Decorators, node: ast.expr, context: str) -> None: - """Mark an exact interface with the native pure characteristic.""" + """Mark a module-level native procedure with the Fortran pure characteristic.""" if isinstance(node, ast.Call): raise ValueError("pure does not accept arguments") if context != ".pyi": - raise ValueError("pure is only valid for module-level prototype declarations") + raise ValueError("pure is only valid for module-level declarations") if parsed.pure: raise ValueError("Duplicate pure decorator") parsed.pure = True @@ -3840,6 +3840,10 @@ def _visit_FunctionDef(self, node: ast.FunctionDef) -> None: error_status_policy=decorators.error_status_policy, restates_projected_result=decorators.overload_target is not None, ) + if decorators.pure: + # The same fact a Fortran source records, which a specification + # function in a declaration expression is required to carry. + function.metadata["fortran_attributes"] = [*function.metadata.get("fortran_attributes", ()), "pure"] if decorators.overload_target is not None: self.parser._pending_overloads.append( _PendingOverload( diff --git a/tests/fortran/arrays/semantics/test_array_semantics.py b/tests/fortran/arrays/semantics/test_array_semantics.py index f2fd76fa1..5e8a3db5a 100644 --- a/tests/fortran/arrays/semantics/test_array_semantics.py +++ b/tests/fortran/arrays/semantics/test_array_semantics.py @@ -362,3 +362,35 @@ def test_standalone_specification_interface_round_trips_as_one_pure_prototype_si assert ( get_function(reloaded, "values").return_type.storage.array.expression_callables[0][0].placement == "standalone" ) + + +def test_a_pure_function_contract_states_its_purity_and_reads_it_back(): + """A specification function must be pure, so its contract has to say it is. + + The contract wrote `@pure` only on prototypes, so a pure module function + read back impure and a contract calling it in a declaration expression + could not be built. + """ + module = fortran_module_to_semantic_module( + parse_fortran_source(""" +module extent_provider +contains +pure integer function extent_for(n) + integer, intent(in) :: n + extent_for = n + 1 +end function extent_for +integer function plain(n) + integer, intent(in) :: n + plain = n +end function plain +end module extent_provider +""") + ) + complete_python_export_policy(module) + complete_contract_imports([module]) + contract = PyiPrinter(normalize_public_names=True).emit(module) + + assert "@pure\n@native_call([Addr(Arg(0))])\ndef extent_for(" in contract + assert "@pure\n@native_call([Addr(Arg(0))])\ndef plain(" not in contract + reloaded = parse_pyi_text(contract, module_name="extent_provider") + assert [function.metadata.get("fortran_attributes") for function in reloaded.functions] == [["pure"], None] diff --git a/tests/fortran/callbacks/semantics/test_pyi_callback_semantics.py b/tests/fortran/callbacks/semantics/test_pyi_callback_semantics.py index 4da816d3e..017fb3e05 100644 --- a/tests/fortran/callbacks/semantics/test_pyi_callback_semantics.py +++ b/tests/fortran/callbacks/semantics/test_pyi_callback_semantics.py @@ -127,7 +127,7 @@ def values(n: Int32) -> Float64[extent_for(n)]: ... @pytest.mark.parametrize( ("decorators", "message"), [ - ("@pure", "pure requires prototype"), + ('@pure\n@overload("declared_impl")', "an overload dispatcher names none"), ("@standalone\n@prototype", "prototype cannot be combined with standalone"), ], ) @@ -142,6 +142,20 @@ def declared(value: Int32) -> Int32: ... ) +def test_a_pure_module_function_states_the_purity_its_native_procedure_has(): + """A specification function has to be pure, and its contract says that it is.""" + module = parse_pyi_text( + """ +@pure +@native_call([Addr(Arg(0))]) +def extent_for(n: Int32) -> Int32: ... +""", + module_name="pure_function", + ) + + assert module.functions[0].metadata["fortran_attributes"] == ["pure"] + + def test_imported_prototype_resolves_as_module_interface_definition(tmp_path): from prik.pipeline.pyi import pyi_paths_to_semantic_modules from prik.pipeline.wrapper import WrapperGenerator From a2e4f7dec64e3228ddb7ad84a4000f62b1a9fcb0 Mon Sep 17 00:00:00 2001 From: said Date: Sat, 19 Sep 2026 09:37:02 +0100 Subject: [PATCH 82/96] Check an actual against the extent a specification function declares The binding checks every declared extent it can evaluate, but a specification function can only be evaluated in Fortran, so an axis the bridge sizes was skipped. A shorter intent(out) actual such as y(extent_for(n)) was then written past its end, corrupting the heap, and a shorter intent(in) one was read past it. Each argument with such an axis now has an argument_extent entrypoint group: the bridge evaluates the declared extent into it, runs the native procedure only when every actual matches, and the binding raises the same TypeError a binding-checked extent raises, with the cleanup a native status error uses. An omitted optional actual has no extent to check. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 10 +++ prik/codegen/c/binding.py | 76 +++++++++++++++++++ prik/codegen/fortran/bridge.py | 58 ++++++++++++++ prik/pipeline/wrapper.py | 5 ++ prik/planning/planner.py | 7 ++ .../test_declaration_extent_expressions.py | 68 +++++++++++++++++ 6 files changed, 224 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5261a2c6a..d24b67aea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ release tags add a leading `v` to the package version. ## Unreleased +- An array argument sized by a specification function is checked against the + extent its dummy declares. The binding checks every other declared extent, + but only the Fortran bridge can evaluate a specification function, so a + shorter `intent(out)` actual such as `y(extent_for(n))` was written past its + end and corrupted the heap, and a shorter `intent(in)` one was read past it. + The bridge now evaluates the declared extent, runs the native procedure only + when the actual matches, and returns the extent for the binding to raise the + same `TypeError` a mismatched extent always raises. An omitted optional + actual is not checked. + - A generated contract states `@pure` on a pure module function, and a contract may write it there. A declaration expression may call only a pure function, and `@pure` was accepted only on prototypes, so any generated diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index ba4d49adb..31635dd38 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -6689,6 +6689,7 @@ def _visit_FunctionPlan(self, plan: FunctionPlan) -> CFunction: *alias_declarations, *self._callback_context_declarations(plan), *self._declaration_extent_result_declarations(plan), + *self._argument_extent_declarations(plan), *self._direct_result_declaration(plan, context), *self._native_output_declarations(plan, context), self._parse_statement(plan, context), @@ -12269,6 +12270,7 @@ def _output_nodes( *self._derived_after_native_failure_nodes(plan, context), *self._derived_result_allocation_failure_nodes(plan, context), *self._binding_transformation_post_call_nodes(plan, context), + *self._argument_extent_rejection_nodes(plan, context), *self._lower_status_error(plan, context), ] if plan.results or plan.writeback_actions: @@ -13294,6 +13296,69 @@ def _direct_result_declaration( scalar_type = PrimitiveScalarTypeRegistry.type_for(result.semantic_type_name) return (CDeclaration(context.result_name, scalar_type.c_spelling),) + def _argument_extent_declarations(self, plan: FunctionPlan) -> tuple[CDeclaration, ...]: + """Declare storage for each argument extent a specification function declares.""" + return tuple( + CDeclaration(self._argument_extent_name(argument, axis), "int64_t", CodeExpression("0")) + for argument in plan.arguments + for axis in self._bridge_extent_axes(argument) + ) + + def _argument_extent_rejection_nodes( + self, + plan: FunctionPlan, + context: _CFunctionContext, + ) -> tuple[CIf, ...]: + """Reject an actual shorter or longer than the extent its dummy declares. + + Only the bridge can evaluate a specification function. It returned the + extent the dummy declares and ran the native procedure only when every + actual matched, so a mismatch arrives here with nothing called, and is + reported the way a binding-checked extent is. + """ + cleanup = ( + *self._string_replacement_cleanup_nodes(plan, context), + *self._binding_transformation_cleanup_nodes(plan, context), + *self._native_result_failure_cleanup_nodes(plan.results, context), + ) + return tuple( + CIf( + CodeExpression(self._argument_extent_mismatch(argument, axis, context.arguments[argument.owner_path])), + body=( + CExpressionStatement( + CodeExpression( + f'PyErr_SetString(PyExc_TypeError, "Argument {argument.binding.python_name} has ' + f'incompatible shape at axis {axis}")' + ) + ), + *cleanup, + CReturn(CodeExpression("NULL")), + ), + ) + for argument in plan.arguments + for axis in self._bridge_extent_axes(argument) + ) + + def _argument_extent_mismatch(self, argument: ArgumentTransferPlan, axis: int, names: _CArgumentNames) -> str: + """Return when one actual disagrees with the extent its dummy declares.""" + mismatch = f"{self._argument_extent_name(argument, axis)} != {names.extent_names[axis]}" + if argument.entrypoint.optional_mode is OptionalMode.REQUIRED: + return mismatch + # An omitted actual has no extent to disagree with. + return f"{names.object_name} != Py_None && {mismatch}" + + @staticmethod + def _bridge_extent_axes(argument: ArgumentTransferPlan) -> tuple[int, ...]: + """Return the axes of one argument that only the bridge can evaluate.""" + if argument.array is None: + return () + return tuple(axis for axis, evaluation in enumerate(argument.array.extent_evaluation) if evaluation == "bridge") + + @staticmethod + def _argument_extent_name(argument: ArgumentTransferPlan, axis: int) -> str: + """Return the shared entrypoint ABI name for one declared argument extent.""" + return f"{argument.entrypoint.parameter_name}_declared_extent_{axis}" + def _declaration_extent_result_declarations(self, plan: FunctionPlan) -> tuple[CDeclaration, ...]: """Declare storage populated by native-dependent main-bridge extent outputs.""" return tuple( @@ -13790,6 +13855,11 @@ def _entrypoint_parameter_values( if slot.native_scalar_c_type is not None and slot.passing is EntrypointPassingConvention.C_VALUE: values[0] = f"({slot.native_scalar_c_type}){values[0]}" return tuple(values) + if parameter.source_kind == "argument_extent": + argument = self._argument_by_owner(plan, parameter.owner_path) + return tuple( + f"&{self._argument_extent_name(argument, axis)}" for axis in self._bridge_extent_axes(argument) + ) if parameter.source_kind == "projected_slot": return self._projected_slot_values( plan, @@ -14128,6 +14198,12 @@ def _entrypoint_parameter_declarations( self._argument_by_owner(plan, parameter.owner_path), passing=slot.passing, ) + if parameter.source_kind == "argument_extent": + argument = self._argument_by_owner(plan, parameter.owner_path) + return tuple( + CParameter(self._argument_extent_name(argument, axis), "int64_t *") + for axis in self._bridge_extent_axes(argument) + ) if parameter.source_kind == "projected_slot": return self._projected_slot_parameters(self._projected_slot_for_parameter(plan, parameter)) result = self._entrypoint_result_by_owner(plan, parameter.owner_path) diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index 05fbae19e..650e9514e 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -637,6 +637,11 @@ def _visit_FunctionPlan( is_subroutine = plan.bridge.native_is_subroutine or owned_direct_result is not None # Stage 2: assemble the native invocation and its ordered finalizers. function_body, optional_procedures = self._function_body(plan, result_name) + extents_match = self._argument_extents_match(plan) + if extents_match is not None: + # An explicit-shape dummy is as long as its own declaration says, so + # the native procedure runs only when the actual is that long too. + function_body = (FortranIf(CodeExpression(extents_match), body=tuple(function_body)),) native_body = ( *self._derived_pointer_call_initializers(plan), *function_body, @@ -693,6 +698,7 @@ def _visit_FunctionPlan( *self._string_value_initializers(plan), *self._string_address_initializers(plan), *self._declaration_extent_result_assignments(plan), + *self._argument_extent_assignments(plan), *self._direct_array_result_initializers(plan), *derived_body, ), @@ -712,6 +718,8 @@ def _entrypoint_parameter_declarations( """Lower one shared C-ABI parameter group into a bind(C) declaration.""" if parameter.source_kind == "argument": return self.visit(self._argument_by_owner(plan, parameter.owner_path)) + if parameter.source_kind == "argument_extent": + return self._argument_extent_parameters(self._argument_by_owner(plan, parameter.owner_path)) if parameter.source_kind == "projected_slot": return self._projected_slot_parameters(self._projected_slot_for_parameter(plan, parameter)) result = self._result_by_owner(plan, parameter.owner_path) @@ -807,6 +815,56 @@ def _declaration_extent_result_name(result: ResultPlan | NativeEntrypointResultP """Return the shared entrypoint ABI name for one evaluated result axis.""" return f"prik_decl_extent_{result.result_position}_{axis}" + def _argument_extent_parameters(self, argument: ArgumentTransferPlan) -> tuple[FortranParameter, ...]: + """Return the declared extents a specification function sets for one argument.""" + return tuple( + FortranParameter(self._argument_extent_name(argument, axis), "integer(c_int64_t)", ("intent(out)",)) + for axis in self._bridge_extent_axes(argument) + ) + + def _argument_extent_assignments(self, plan: FunctionPlan) -> tuple[FortranAssignment, ...]: + """Evaluate every argument extent a specification function declares.""" + assignments = [] + for argument in plan.arguments: + axes = self._bridge_extent_axes(argument) + if not axes: + continue + shape = self._array_shape_from_roles(argument.array, plan) + assignments.extend( + FortranAssignment( + self._argument_extent_name(argument, axis), CodeExpression(f"int({shape[axis]}, c_int64_t)") + ) + for axis in axes + ) + return tuple(assignments) + + def _argument_extents_match(self, plan: FunctionPlan) -> str | None: + """Return when every declared argument extent equals the actual one, or ``None``.""" + role_names = self._array_shape_role_names(plan) + conditions = [] + for argument in plan.arguments: + for axis in self._bridge_extent_axes(argument): + matches = ( + f"{self._argument_extent_name(argument, axis)} == {role_names[argument.array.extent_roles[axis]]}" + ) + if argument.entrypoint.optional_mode is not OptionalMode.REQUIRED: + # An omitted actual has no extent to disagree with. + matches = f"(.not. {self._presence_condition(argument)} .or. {matches})" + conditions.append(matches) + return " .and. ".join(conditions) or None + + @staticmethod + def _bridge_extent_axes(argument: ArgumentTransferPlan) -> tuple[int, ...]: + """Return the axes of one argument that only the bridge can evaluate.""" + if argument.array is None: + return () + return tuple(axis for axis, evaluation in enumerate(argument.array.extent_evaluation) if evaluation == "bridge") + + @staticmethod + def _argument_extent_name(argument: ArgumentTransferPlan, axis: int) -> str: + """Return the shared entrypoint ABI name for one declared argument extent.""" + return f"{argument.entrypoint.parameter_name}_declared_extent_{axis}" + # Immediate callback adapters. def _callback_standalone_adapter_procedure( self, diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index 76bdbe1e3..cf1794438 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -1716,6 +1716,11 @@ def _expected_entrypoint_parameter_groups(plan: FunctionPlan) -> tuple[tuple[str for result in plan.results if result.array is not None and "bridge" in result.array.extent_evaluation ) + groups.extend( + (argument.owner_path, "argument_extent") + for argument in plan.arguments + if argument.array is not None and "bridge" in argument.array.extent_evaluation + ) return tuple(groups) def _entrypoint_parameter_name_diagnostics( diff --git a/prik/planning/planner.py b/prik/planning/planner.py index dc6fbd866..469cfc624 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -1549,6 +1549,13 @@ def _entrypoint_parameter_plans( for result in results if result.array is not None and "bridge" in result.array.extent_evaluation ) + # Only the bridge can evaluate a specification function, so it hands + # back the extent one declares for the binding to check the actual by. + groups.extend( + (argument.owner_path, "argument_extent", None) + for argument in arguments + if argument.array is not None and "bridge" in argument.array.extent_evaluation + ) return tuple( NativeEntrypointParameterPlan( owner_path=owner, diff --git a/tests/fortran/arrays/end_to_end/test_declaration_extent_expressions.py b/tests/fortran/arrays/end_to_end/test_declaration_extent_expressions.py index 955aebf27..d95e1e461 100644 --- a/tests/fortran/arrays/end_to_end/test_declaration_extent_expressions.py +++ b/tests/fortran/arrays/end_to_end/test_declaration_extent_expressions.py @@ -6,6 +6,7 @@ import pytest from tests.fortran._support.wrapper_build import ( + _build_generated_pyi_and_import, _build_inline_pyi_contract_module, _build_sources_and_import, _build_text_and_import, @@ -426,3 +427,70 @@ def test_specification_functions_python_must_rename_still_size_their_results(tmp contract = (tmp_path / "contracts" / "reserved_extent_owner.pyi").read_text(encoding="utf-8") assert "-> Float64[lambda_(n)]" in contract assert "-> Float64[2 * lambda__2(n) + n]" in contract + + +CHECKED_EXTENT_SOURCE = """ +module checked_extent_provider + implicit none +contains + pure integer function extent_for(n) + integer, intent(in) :: n + extent_for = n + 1 + end function extent_for +end module checked_extent_provider + +module checked_extent_owner + use, intrinsic :: iso_c_binding, only: c_double + use checked_extent_provider, only: extent_for + implicit none +contains + subroutine fill(n, values) + integer, intent(in) :: n + real(c_double), intent(out) :: values(extent_for(n)) + values = 2.0_c_double + end subroutine fill + real(c_double) function total(n, values) + integer, intent(in) :: n + real(c_double), intent(in) :: values(extent_for(n)) + total = sum(values) + end function total + subroutine maybe_fill(n, values) + integer, intent(in) :: n + real(c_double), intent(inout), optional :: values(extent_for(n)) + if (present(values)) values = 5.0_c_double + end subroutine maybe_fill +end module checked_extent_owner +""" + + +@pytest.mark.parametrize("lane", ["source", "generated_pyi"]) +def test_an_actual_is_checked_against_the_extent_a_specification_function_declares(tmp_path: Path, lane: str): + """An explicit-shape dummy is as long as its declaration says, whoever sizes it. + + Only the Fortran bridge can evaluate a specification function, so the + binding skipped the check it makes for every other extent. A shorter + `intent(out)` actual was then written past its end and a shorter + `intent(in)` one read past it. The contract states the function is pure, + so the generated contract builds and checks the same way the source does. + """ + if lane == "source": + package, _payload = _build_sources_and_import([("checked_extent.f90", CHECKED_EXTENT_SOURCE)], tmp_path) + else: + source = tmp_path / "checked_extent.f90" + source.write_text(CHECKED_EXTENT_SOURCE, encoding="utf-8") + package = _build_generated_pyi_and_import(source, tmp_path / "replay") + owner = package.checked_extent_owner + + values = np.zeros(4) + owner.fill(np.int32(3), values) + np.testing.assert_array_equal(values, np.full(4, 2.0)) + assert owner.total(np.int32(3), np.ones(4)) == 4.0 + owner.maybe_fill(np.int32(3)) + for call in ( + lambda: owner.fill(np.int32(3), np.zeros(3)), + lambda: owner.fill(np.int32(3), np.zeros(5)), + lambda: owner.total(np.int32(3), np.ones(3)), + lambda: owner.maybe_fill(np.int32(3), np.zeros(3)), + ): + with pytest.raises(TypeError, match="has incompatible shape at axis 0"): + call() From 1b26c74166e9115e4819314fd6fab7c2c34a95d6 Mon Sep 17 00:00:00 2001 From: said Date: Sat, 19 Sep 2026 10:32:55 +0100 Subject: [PATCH 83/96] Skip the whole call on an extent mismatch, not only the native procedure a2e4f7de guarded the native call alone. On a mismatch the bridge still converted its actuals, allocated the result, malloc'd result storage, and copied the never-written result into it; the binding then ran the derived-transaction checks and the array copy-back before rejecting. The bridge now evaluates each declared argument extent from its parameters before anything else and runs the rest of the procedure only when every actual matches, so a mismatch prepares, calls, and produces nothing and returns a null pointer result. The binding rejects as the first step after the call, releasing only what it took before the call. That release -- string buffers, array temporaries, and native result storage -- was spelled out in seven post-call failure paths; it is now one helper they all read. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 9 ++- prik/codegen/c/binding.py | 78 ++++++++----------- prik/codegen/fortran/bridge.py | 67 +++++++++++----- ...est_specification_extent_check_lowering.py | 59 ++++++++++++++ 4 files changed, 141 insertions(+), 72 deletions(-) create mode 100644 tests/fortran/arrays/codegen/test_specification_extent_check_lowering.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d24b67aea..b3c4420f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,11 @@ release tags add a leading `v` to the package version. but only the Fortran bridge can evaluate a specification function, so a shorter `intent(out)` actual such as `y(extent_for(n))` was written past its end and corrupted the heap, and a shorter `intent(in)` one was read past it. - The bridge now evaluates the declared extent, runs the native procedure only - when the actual matches, and returns the extent for the binding to raise the - same `TypeError` a mismatched extent always raises. An omitted optional - actual is not checked. + The bridge now evaluates the declared extent from its parameters before + anything else and runs the rest of the procedure only when the actual + matches, so a mismatch prepares, calls, and produces nothing; the binding + raises the same `TypeError` a mismatched extent always raises before any + other post-call step. An omitted optional actual is not checked. - A generated contract states `@pure` on a pure module function, and a contract may write it there. A declaration expression may call only a pure diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 31635dd38..4ef39604a 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -12266,11 +12266,11 @@ def _output_nodes( *self._callback_context_push_nodes(plan, context), *self._lower_entrypoint_call(plan, context), *self._callback_context_pop_nodes(plan), + *self._argument_extent_rejection_nodes(plan, context), *self._derived_call_failure_nodes(plan, context), *self._derived_after_native_failure_nodes(plan, context), *self._derived_result_allocation_failure_nodes(plan, context), *self._binding_transformation_post_call_nodes(plan, context), - *self._argument_extent_rejection_nodes(plan, context), *self._lower_status_error(plan, context), ] if plan.results or plan.writeback_actions: @@ -12481,9 +12481,7 @@ def _derived_after_native_failure_nodes( CIf( CodeExpression(f"{fault} != NULL && {fault}[0] != '\\0' && {fault}[0] != '0'"), body=( - *self._string_replacement_cleanup_nodes(plan, context), - *self._binding_transformation_cleanup_nodes(plan, context), - *self._native_result_failure_cleanup_nodes(plan.results, context), + *self._post_call_failure_cleanup_nodes(plan, context), CExpressionStatement( CodeExpression( 'PyErr_SetString(PyExc_RuntimeError, "injected derived failure after native return")' @@ -12505,9 +12503,7 @@ def _derived_call_failure_nodes( CIf( CodeExpression(f"{self._derived_status_name(context.arguments[argument.owner_path])} != 0"), body=( - *self._string_replacement_cleanup_nodes(plan, context), - *self._binding_transformation_cleanup_nodes(plan, context), - *self._native_result_failure_cleanup_nodes(plan.results, context), + *self._post_call_failure_cleanup_nodes(plan, context), *self._one_derived_call_error_nodes(argument, context), CReturn(CodeExpression("NULL")), ), @@ -12559,11 +12555,7 @@ def _derived_result_allocation_failure_nodes( if not derived: return () native_names = tuple(self._result_native_name(result, context) for result in derived) - cleanup = [ - *self._string_replacement_cleanup_nodes(plan, context), - *self._binding_transformation_cleanup_nodes(plan, context), - *self._native_result_failure_cleanup_nodes(plan.results, context), - ] + cleanup = self._post_call_failure_cleanup_nodes(plan, context) return ( CIf( CodeExpression(" || ".join(f"{name} == NULL" for name in native_names)), @@ -12761,9 +12753,7 @@ def _lower_status_error_runtime_error( policy = plan.binding.status_error status_name = context.native_outputs[policy.status_role] condition = CodeExpression(f"{status_name} != {policy.success}") - transformation_cleanup = self._binding_transformation_cleanup_nodes(plan, context) - string_cleanup = self._string_replacement_cleanup_nodes(plan, context) - native_result_cleanup = self._native_result_failure_cleanup_nodes(plan.results, context) + cleanup = self._post_call_failure_cleanup_nodes(plan, context) if policy.message_role is None and policy.message_argument is None: return ( CIf( @@ -12775,9 +12765,7 @@ def _lower_status_error_runtime_error( f"(int){status_name})" ) ), - *string_cleanup, - *transformation_cleanup, - *native_result_cleanup, + *cleanup, CReturn(CodeExpression("NULL")), ), ), @@ -12837,17 +12825,13 @@ def _lower_status_error_runtime_error( CIf( CodeExpression(f"{message_object} == NULL"), body=( - *string_cleanup, - *transformation_cleanup, - *native_result_cleanup, + *cleanup, CReturn(CodeExpression("NULL")), ), ), CExpressionStatement(CodeExpression(f"PyErr_SetObject(PyExc_RuntimeError, {message_object})")), CExpressionStatement(CodeExpression(f"Py_DECREF({message_object})")), - *string_cleanup, - *transformation_cleanup, - *native_result_cleanup, + *cleanup, CReturn(CodeExpression("NULL")), ), ), @@ -12861,9 +12845,7 @@ def _lower_status_error_runtime_error( CodeExpression(f"{message_name} == NULL"), body=( CExpressionStatement(CodeExpression("PyErr_NoMemory()")), - *string_cleanup, - *transformation_cleanup, - *native_result_cleanup, + *cleanup, CReturn(CodeExpression("NULL")), ), ), @@ -12883,9 +12865,7 @@ def _lower_status_error_runtime_error( CIf( CodeExpression(f"{message_object} == NULL"), body=( - *string_cleanup, - *transformation_cleanup, - *native_result_cleanup, + *cleanup, CReturn(CodeExpression("NULL")), ), ), @@ -12894,9 +12874,7 @@ def _lower_status_error_runtime_error( body=( CExpressionStatement(CodeExpression(f"PyErr_SetObject(PyExc_RuntimeError, {message_object})")), CExpressionStatement(CodeExpression(f"Py_DECREF({message_object})")), - *string_cleanup, - *transformation_cleanup, - *native_result_cleanup, + *cleanup, CReturn(CodeExpression("NULL")), ), ), @@ -13316,11 +13294,7 @@ def _argument_extent_rejection_nodes( actual matched, so a mismatch arrives here with nothing called, and is reported the way a binding-checked extent is. """ - cleanup = ( - *self._string_replacement_cleanup_nodes(plan, context), - *self._binding_transformation_cleanup_nodes(plan, context), - *self._native_result_failure_cleanup_nodes(plan.results, context), - ) + cleanup = self._post_call_failure_cleanup_nodes(plan, context) return tuple( CIf( CodeExpression(self._argument_extent_mismatch(argument, axis, context.arguments[argument.owner_path])), @@ -13339,6 +13313,23 @@ def _argument_extent_rejection_nodes( for axis in self._bridge_extent_axes(argument) ) + def _post_call_failure_cleanup_nodes( + self, + plan: FunctionPlan, + context: _CFunctionContext, + ) -> tuple[CExpressionStatement, ...]: + """Release what the call path holds when it fails after the entrypoint returns. + + That is the string buffers and array temporaries taken before the call + and any native result storage; every release is safe for storage the + call did not produce. + """ + return ( + *self._string_replacement_cleanup_nodes(plan, context), + *self._binding_transformation_cleanup_nodes(plan, context), + *self._native_result_failure_cleanup_nodes(plan.results, context), + ) + def _argument_extent_mismatch(self, argument: ArgumentTransferPlan, axis: int, names: _CArgumentNames) -> str: """Return when one actual disagrees with the extent its dummy declares.""" mismatch = f"{self._argument_extent_name(argument, axis)} != {names.extent_names[axis]}" @@ -13667,9 +13658,7 @@ def _binding_transformation_post_call_nodes( ) -> tuple[CExpressionStatement | CIf, ...]: """Copy back ordinary temporaries and retain published replacements.""" nodes = [] - cleanup = self._binding_transformation_cleanup_nodes(plan, context) - string_cleanup = self._string_replacement_cleanup_nodes(plan, context) - native_result_cleanup = self._native_result_failure_cleanup_nodes(plan.results, context) + cleanup = self._post_call_failure_cleanup_nodes(plan, context) for argument in plan.arguments: action = self._transformation_action(argument, WritebackPhase.COPY_OUT) if action is not TransformationAction.COPY_ARRAY_REPRESENTATION: @@ -13681,12 +13670,7 @@ def _binding_transformation_post_call_nodes( CodeExpression( f"PyArray_CopyInto((PyArrayObject *){names.object_name}, (PyArrayObject *){temporary}) < 0" ), - body=( - *string_cleanup, - *cleanup, - *native_result_cleanup, - CReturn(CodeExpression("NULL")), - ), + body=(*cleanup, CReturn(CodeExpression("NULL"))), ) ) nodes.extend(self._binding_transformation_success_cleanup_nodes(plan, context)) diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index 650e9514e..04c165f89 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -637,11 +637,6 @@ def _visit_FunctionPlan( is_subroutine = plan.bridge.native_is_subroutine or owned_direct_result is not None # Stage 2: assemble the native invocation and its ordered finalizers. function_body, optional_procedures = self._function_body(plan, result_name) - extents_match = self._argument_extents_match(plan) - if extents_match is not None: - # An explicit-shape dummy is as long as its own declaration says, so - # the native procedure runs only when the actual is that long too. - function_body = (FortranIf(CodeExpression(extents_match), body=tuple(function_body)),) native_body = ( *self._derived_pointer_call_initializers(plan), *function_body, @@ -685,22 +680,26 @@ def _visit_FunctionPlan( *self._native_output_declarations(plan), *self._derived_result_allocation_declarations(plan), ), - body=( - *self._character_local_initializers(plan), - *self._native_array_owner_initializers(plan), - *self._descriptor_initializers(plan), - *self._required_descriptor_initializers(plan), - *self._logical_scalar_argument_initializers(plan), - *self._opaque_address_initializers(plan), - *self._array_initializers(plan), - *self._logical_array_argument_initializers(plan), - *self._raw_array_address_initializers(plan), - *self._string_value_initializers(plan), - *self._string_address_initializers(plan), - *self._declaration_extent_result_assignments(plan), - *self._argument_extent_assignments(plan), - *self._direct_array_result_initializers(plan), - *derived_body, + body=self._extent_checked_body( + plan, + result_name, + result_type, + ( + *self._character_local_initializers(plan), + *self._native_array_owner_initializers(plan), + *self._descriptor_initializers(plan), + *self._required_descriptor_initializers(plan), + *self._logical_scalar_argument_initializers(plan), + *self._opaque_address_initializers(plan), + *self._array_initializers(plan), + *self._logical_array_argument_initializers(plan), + *self._raw_array_address_initializers(plan), + *self._string_value_initializers(plan), + *self._string_address_initializers(plan), + *self._declaration_extent_result_assignments(plan), + *self._direct_array_result_initializers(plan), + *derived_body, + ), ), is_subroutine=is_subroutine, internal_procedures=( @@ -815,6 +814,32 @@ def _declaration_extent_result_name(result: ResultPlan | NativeEntrypointResultP """Return the shared entrypoint ABI name for one evaluated result axis.""" return f"prik_decl_extent_{result.result_position}_{axis}" + def _extent_checked_body( + self, + plan: FunctionPlan, + result_name: str | None, + result_type: str | None, + body: tuple, + ) -> tuple: + """Run the whole procedure only when every checked actual has its declared extent. + + A dummy a specification function sizes is as long as that function + says, and only Fortran can evaluate it. The extents are read from the + parameters alone, before anything is prepared, so an actual of another + length leaves nothing converted, called, allocated, or produced; a + pointer result is null, and the binding reports the declared extent. + """ + extents_match = self._argument_extents_match(plan) + if extents_match is None: + return body + unproduced = ( + (FortranAssignment(result_name, CodeExpression("c_null_ptr")),) if result_type == "type(c_ptr)" else () + ) + return ( + *self._argument_extent_assignments(plan), + FortranIf(CodeExpression(extents_match), body=body, else_body=unproduced), + ) + def _argument_extent_parameters(self, argument: ArgumentTransferPlan) -> tuple[FortranParameter, ...]: """Return the declared extents a specification function sets for one argument.""" return tuple( diff --git a/tests/fortran/arrays/codegen/test_specification_extent_check_lowering.py b/tests/fortran/arrays/codegen/test_specification_extent_check_lowering.py new file mode 100644 index 000000000..ea68b654d --- /dev/null +++ b/tests/fortran/arrays/codegen/test_specification_extent_check_lowering.py @@ -0,0 +1,59 @@ +"""An argument sized by a specification function is checked before anything else runs.""" + +from __future__ import annotations + +from tests.fortran._support.ownership_policy import parse_pyi_text +from tests.fortran._support.printer_models import generate_wrapper, rendered_source + +CONTRACT = """ +from prik.contracts import Addr, Annotated, Arg, COPY_F, Float64, Int32, ORDER_C, native_call, pure + +@pure +@native_call([Addr(Arg(0))]) +def extent_for(n: Int32) -> Int32: ... + +@native_call([Addr(Arg(0)), Arg(1), Arg(2)]) +def scaled( + n: Int32, + values: Float64[extent_for(n)], + grid: Annotated[Float64[n, n], ORDER_C, COPY_F], +) -> Float64[extent_for(n)]: ... +""" + + +def _sources() -> tuple[str, str]: + artifacts = generate_wrapper(parse_pyi_text(CONTRACT, module_name="extents")) + return rendered_source(artifacts, ".f90"), rendered_source(artifacts, ".c") + + +def test_a_mismatched_actual_leaves_the_bridge_nothing_prepared_called_or_produced(): + """Only the declared extent is evaluated before the check; a mismatch yields a null result. + + Guarding the native call alone still converted the actuals, allocated the + result, and copied storage the call never wrote into it. + """ + bridge, _binding = _sources() + body = bridge[bridge.index("function bind_c_scaled(") : bridge.index("end function bind_c_scaled")] + statements = [ + line.strip() + for line in body.splitlines() + if line.strip() and "::" not in line and "&" not in line and "bind(c" not in line + ] + + assert statements[0].startswith("values_declared_extent_0 = int(") + assert statements[1] == "if (values_declared_extent_0 == values_extent_0) then" + # Everything that prepares, calls, or produces sits inside that branch. + assert statements[-3:] == ["else", "result = c_null_ptr", "end if"] + + +def test_the_binding_rejects_before_any_other_post_call_step(): + """The rejection is the first thing after the call, holding nothing the call produced. + + `grid` is copied through a Fortran-order temporary, whose copy-back is a + post-call step; a rejected call must not reach it. + """ + _bridge, binding = _sources() + after_call = binding[binding.index("= bind_c_scaled(") :].splitlines()[1:] + checks = [line.strip() for line in after_call if line.strip().startswith("if (")] + + assert checks[0] == "if (values_declared_extent_0 != bound_values_extent_0) {" From efb733e8ed10c581698cae8cf48132a578d49f5e Mon Sep 17 00:00:00 2001 From: said Date: Sat, 19 Sep 2026 11:59:29 +0100 Subject: [PATCH 84/96] Plan each bridge-evaluated extent once, and pin the rejection boundary The entrypoint plan recorded only that an argument or result had an extent group; the C binding and the Fortran bridge each re-derived which axes the bridge evaluates and what the output carrying each is called. NativeEntrypointParameterPlan now carries those extents -- axis and ABI name -- and both backends read them: the paired _bridge_extent_axes, _argument_extent_name, and _declaration_extent_result_* helpers are deleted, and one group kind set covers result and argument extents alike. The rejection boundary 1b26c741 drew gets the runtime guards the review asked for: an array crossing through a COPY_F temporary is left untouched by a rejected call, and a derived result a rejected call never produced surfaces the shape error rather than a missing-result error. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- prik/codegen/c/binding.py | 123 +++++------------- prik/codegen/fortran/bridge.py | 123 +++++------------- prik/planning/models.py | 20 +++ prik/planning/planner.py | 49 +++++-- .../test_declaration_extent_expressions.py | 76 +++++++++++ 5 files changed, 204 insertions(+), 187 deletions(-) diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 4ef39604a..7db6a5f2e 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -120,6 +120,7 @@ NativeEntrypointABIValuePlan, GeneratedSupportProcedureImplementationOwner, GeneratedSupportProcedureEntrypointPlan, + NativeEntrypointExtentPlan, NativeEntrypointParameterPlan, NativeEntrypointProjectedSlotPlan, NativeEntrypointResultPlan, @@ -161,6 +162,10 @@ class _CArgumentNames: polymorphic_name: str +#: Entrypoint groups carrying extents the bridge evaluates and hands back. +_EXTENT_GROUPS = frozenset({"declaration_extent", "argument_extent"}) + + @dataclass class _CFunctionContext: """Per-function names and role substitutions shared across C lowering. @@ -6688,8 +6693,7 @@ def _visit_FunctionPlan(self, plan: FunctionPlan) -> CFunction: *argument_declarations, *alias_declarations, *self._callback_context_declarations(plan), - *self._declaration_extent_result_declarations(plan), - *self._argument_extent_declarations(plan), + *self._entrypoint_extent_declarations(plan), *self._direct_result_declaration(plan, context), *self._native_output_declarations(plan, context), self._parse_statement(plan, context), @@ -9908,9 +9912,10 @@ def _result_extent_expression( expression: str, context: _CFunctionContext, ) -> str: - """Use the entrypoint result for native axes and local roles for all others.""" - if handoff.extent_evaluation[axis] == "bridge": - return self._declaration_extent_result_name(result, axis) + """Use the extent the bridge returned for its axes and local roles for all others.""" + evaluated = context.function.entrypoint.extent_names(result.owner_path).get(axis) + if evaluated is not None: + return evaluated return self._array_extent_expression(handoff, axis, expression, context) def _array_result_creation_expression( @@ -13274,12 +13279,13 @@ def _direct_result_declaration( scalar_type = PrimitiveScalarTypeRegistry.type_for(result.semantic_type_name) return (CDeclaration(context.result_name, scalar_type.c_spelling),) - def _argument_extent_declarations(self, plan: FunctionPlan) -> tuple[CDeclaration, ...]: - """Declare storage for each argument extent a specification function declares.""" + @staticmethod + def _entrypoint_extent_declarations(plan: FunctionPlan) -> tuple[CDeclaration, ...]: + """Declare storage for every extent the bridge evaluates and hands back.""" return tuple( - CDeclaration(self._argument_extent_name(argument, axis), "int64_t", CodeExpression("0")) - for argument in plan.arguments - for axis in self._bridge_extent_axes(argument) + CDeclaration(extent.parameter_name, "int64_t", CodeExpression("0")) + for parameter in plan.entrypoint.parameters + for extent in parameter.extents ) def _argument_extent_rejection_nodes( @@ -13297,20 +13303,24 @@ def _argument_extent_rejection_nodes( cleanup = self._post_call_failure_cleanup_nodes(plan, context) return tuple( CIf( - CodeExpression(self._argument_extent_mismatch(argument, axis, context.arguments[argument.owner_path])), + CodeExpression( + self._argument_extent_mismatch(argument, extent, context.arguments[argument.owner_path]) + ), body=( CExpressionStatement( CodeExpression( f'PyErr_SetString(PyExc_TypeError, "Argument {argument.binding.python_name} has ' - f'incompatible shape at axis {axis}")' + f'incompatible shape at axis {extent.axis}")' ) ), *cleanup, CReturn(CodeExpression("NULL")), ), ) - for argument in plan.arguments - for axis in self._bridge_extent_axes(argument) + for parameter in plan.entrypoint.parameters + if parameter.source_kind == "argument_extent" + for argument in (self._argument_by_owner(plan, parameter.owner_path),) + for extent in parameter.extents ) def _post_call_failure_cleanup_nodes( @@ -13330,40 +13340,19 @@ def _post_call_failure_cleanup_nodes( *self._native_result_failure_cleanup_nodes(plan.results, context), ) - def _argument_extent_mismatch(self, argument: ArgumentTransferPlan, axis: int, names: _CArgumentNames) -> str: + @staticmethod + def _argument_extent_mismatch( + argument: ArgumentTransferPlan, + extent: NativeEntrypointExtentPlan, + names: _CArgumentNames, + ) -> str: """Return when one actual disagrees with the extent its dummy declares.""" - mismatch = f"{self._argument_extent_name(argument, axis)} != {names.extent_names[axis]}" + mismatch = f"{extent.parameter_name} != {names.extent_names[extent.axis]}" if argument.entrypoint.optional_mode is OptionalMode.REQUIRED: return mismatch # An omitted actual has no extent to disagree with. return f"{names.object_name} != Py_None && {mismatch}" - @staticmethod - def _bridge_extent_axes(argument: ArgumentTransferPlan) -> tuple[int, ...]: - """Return the axes of one argument that only the bridge can evaluate.""" - if argument.array is None: - return () - return tuple(axis for axis, evaluation in enumerate(argument.array.extent_evaluation) if evaluation == "bridge") - - @staticmethod - def _argument_extent_name(argument: ArgumentTransferPlan, axis: int) -> str: - """Return the shared entrypoint ABI name for one declared argument extent.""" - return f"{argument.entrypoint.parameter_name}_declared_extent_{axis}" - - def _declaration_extent_result_declarations(self, plan: FunctionPlan) -> tuple[CDeclaration, ...]: - """Declare storage populated by native-dependent main-bridge extent outputs.""" - return tuple( - CDeclaration( - self._declaration_extent_result_name(result, axis), - "int64_t", - CodeExpression("0"), - ) - for result in plan.results - if result.array is not None - for axis, evaluation in enumerate(result.array.extent_evaluation) - if evaluation == "bridge" - ) - def _native_output_declarations( self, plan: FunctionPlan, @@ -13839,11 +13828,8 @@ def _entrypoint_parameter_values( if slot.native_scalar_c_type is not None and slot.passing is EntrypointPassingConvention.C_VALUE: values[0] = f"({slot.native_scalar_c_type}){values[0]}" return tuple(values) - if parameter.source_kind == "argument_extent": - argument = self._argument_by_owner(plan, parameter.owner_path) - return tuple( - f"&{self._argument_extent_name(argument, axis)}" for axis in self._bridge_extent_axes(argument) - ) + if parameter.source_kind in _EXTENT_GROUPS: + return tuple(f"&{extent.parameter_name}" for extent in parameter.extents) if parameter.source_kind == "projected_slot": return self._projected_slot_values( plan, @@ -13856,8 +13842,6 @@ def _entrypoint_parameter_values( return self._entrypoint_hidden_result_values(result, name) if parameter.source_kind == "direct_result": return self._entrypoint_direct_result_values(result, context) - if parameter.source_kind == "declaration_extent": - return self._declaration_extent_result_values_for_result(result) raise ValueError(f"Unsupported entrypoint parameter group {parameter.source_kind!r}") @staticmethod @@ -13977,19 +13961,6 @@ def _entrypoint_hidden_results(self, plan: FunctionPlan) -> tuple[NativeEntrypoi if parameter.source_kind == "hidden_result" ) - def _declaration_extent_result_values_for_result( - self, - result: NativeEntrypointResultPlan, - ) -> tuple[str, ...]: - """Return extent output actuals for one planned result group.""" - if result.array is None: - return () - return tuple( - f"&{self._declaration_extent_result_name(result, axis)}" - for axis, evaluation in enumerate(result.array.extent_evaluation) - if evaluation == "bridge" - ) - def _entrypoint_hidden_result_values( self, result: NativeEntrypointResultPlan, @@ -14182,12 +14153,8 @@ def _entrypoint_parameter_declarations( self._argument_by_owner(plan, parameter.owner_path), passing=slot.passing, ) - if parameter.source_kind == "argument_extent": - argument = self._argument_by_owner(plan, parameter.owner_path) - return tuple( - CParameter(self._argument_extent_name(argument, axis), "int64_t *") - for axis in self._bridge_extent_axes(argument) - ) + if parameter.source_kind in _EXTENT_GROUPS: + return tuple(CParameter(extent.parameter_name, "int64_t *") for extent in parameter.extents) if parameter.source_kind == "projected_slot": return self._projected_slot_parameters(self._projected_slot_for_parameter(plan, parameter)) result = self._entrypoint_result_by_owner(plan, parameter.owner_path) @@ -14195,28 +14162,8 @@ def _entrypoint_parameter_declarations( return self._entrypoint_result_parameters(result) if parameter.source_kind == "direct_result": return self._direct_entrypoint_result_parameters(result) - if parameter.source_kind == "declaration_extent": - return self._declaration_extent_result_parameters_for_result(result) raise ValueError(f"Unsupported entrypoint parameter group {parameter.source_kind!r}") - def _declaration_extent_result_parameters_for_result( - self, - result: NativeEntrypointResultPlan, - ) -> tuple[CParameter, ...]: - """Declare native-dependent extent outputs for one result group.""" - if result.array is None: - return () - return tuple( - CParameter(self._declaration_extent_result_name(result, axis), "int64_t *") - for axis, evaluation in enumerate(result.array.extent_evaluation) - if evaluation == "bridge" - ) - - @staticmethod - def _declaration_extent_result_name(result: ResultPlan | NativeEntrypointResultPlan, axis: int) -> str: - """Return the shared entrypoint ABI name for one evaluated result axis.""" - return f"prik_decl_extent_{result.result_position}_{axis}" - def _owned_native_array_bridge_prototypes(self, plan: ModulePlan) -> tuple[CFunctionPrototype, ...]: """Declare typed Fortran operations over binding-owned result descriptors.""" return tuple( diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index 04c165f89..fb5bfaf16 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -189,6 +189,10 @@ def _plan_semantic_type_names(node: object, _seen: set[int] | None = None) -> fr return frozenset(names) +#: Entrypoint groups carrying extents the bridge evaluates and hands back. +_EXTENT_GROUPS = frozenset({"declaration_extent", "argument_extent"}) + + class FortranBridgeGenerator(ClassVisitor): """Build the Fortran half of a wrapper from validated bridge-plan views. @@ -696,7 +700,7 @@ def _visit_FunctionPlan( *self._raw_array_address_initializers(plan), *self._string_value_initializers(plan), *self._string_address_initializers(plan), - *self._declaration_extent_result_assignments(plan), + *self._extent_assignments(plan, "declaration_extent"), *self._direct_array_result_initializers(plan), *derived_body, ), @@ -717,8 +721,11 @@ def _entrypoint_parameter_declarations( """Lower one shared C-ABI parameter group into a bind(C) declaration.""" if parameter.source_kind == "argument": return self.visit(self._argument_by_owner(plan, parameter.owner_path)) - if parameter.source_kind == "argument_extent": - return self._argument_extent_parameters(self._argument_by_owner(plan, parameter.owner_path)) + if parameter.source_kind in _EXTENT_GROUPS: + return tuple( + FortranParameter(extent.parameter_name, "integer(c_int64_t)", ("intent(out)",)) + for extent in parameter.extents + ) if parameter.source_kind == "projected_slot": return self._projected_slot_parameters(self._projected_slot_for_parameter(plan, parameter)) result = self._result_by_owner(plan, parameter.owner_path) @@ -729,8 +736,6 @@ def _entrypoint_parameter_declarations( *self._owned_direct_result_parameters(result), *self._scalar_descriptor_direct_result_parameters_for_result(result), ) - if parameter.source_kind == "declaration_extent": - return self._declaration_extent_result_parameters_for_result(result) raise ValueError(f"Unsupported entrypoint parameter group {parameter.source_kind!r}") @staticmethod @@ -775,45 +780,6 @@ def _projected_slot_parameters( raise ValueError(f"Unsupported projected Fortran parameter passing {slot.passing.value!r}") return (FortranParameter(slot.native_name.casefold(), type_name, attributes),) - def _declaration_extent_result_parameters_for_result( - self, - result: NativeEntrypointResultPlan, - ) -> tuple[FortranParameter, ...]: - """Expose bridge-evaluated extents for one entrypoint result group.""" - if result.array is None: - return () - return tuple( - FortranParameter( - self._declaration_extent_result_name(result, axis), - "integer(c_int64_t)", - ("intent(out)",), - ) - for axis, evaluation in enumerate(result.array.extent_evaluation) - if evaluation == "bridge" - ) - - def _declaration_extent_result_assignments(self, plan: FunctionPlan) -> tuple[FortranAssignment, ...]: - """Evaluate native-dependent result axes inside the Fortran bridge.""" - assignments = [] - for result in plan.results: - if result.array is None or "bridge" not in result.array.extent_evaluation: - continue - shape = self._array_shape_from_roles(result.array, plan) - assignments.extend( - FortranAssignment( - self._declaration_extent_result_name(result, axis), - CodeExpression(f"int({shape[axis]}, c_int64_t)"), - ) - for axis, evaluation in enumerate(result.array.extent_evaluation) - if evaluation == "bridge" - ) - return tuple(assignments) - - @staticmethod - def _declaration_extent_result_name(result: ResultPlan | NativeEntrypointResultPlan, axis: int) -> str: - """Return the shared entrypoint ABI name for one evaluated result axis.""" - return f"prik_decl_extent_{result.result_position}_{axis}" - def _extent_checked_body( self, plan: FunctionPlan, @@ -836,59 +802,43 @@ def _extent_checked_body( (FortranAssignment(result_name, CodeExpression("c_null_ptr")),) if result_type == "type(c_ptr)" else () ) return ( - *self._argument_extent_assignments(plan), + *self._extent_assignments(plan, "argument_extent"), FortranIf(CodeExpression(extents_match), body=body, else_body=unproduced), ) - def _argument_extent_parameters(self, argument: ArgumentTransferPlan) -> tuple[FortranParameter, ...]: - """Return the declared extents a specification function sets for one argument.""" - return tuple( - FortranParameter(self._argument_extent_name(argument, axis), "integer(c_int64_t)", ("intent(out)",)) - for axis in self._bridge_extent_axes(argument) - ) - - def _argument_extent_assignments(self, plan: FunctionPlan) -> tuple[FortranAssignment, ...]: - """Evaluate every argument extent a specification function declares.""" - assignments = [] - for argument in plan.arguments: - axes = self._bridge_extent_axes(argument) - if not axes: - continue - shape = self._array_shape_from_roles(argument.array, plan) - assignments.extend( - FortranAssignment( - self._argument_extent_name(argument, axis), CodeExpression(f"int({shape[axis]}, c_int64_t)") - ) - for axis in axes - ) - return tuple(assignments) - def _argument_extents_match(self, plan: FunctionPlan) -> str | None: """Return when every declared argument extent equals the actual one, or ``None``.""" role_names = self._array_shape_role_names(plan) conditions = [] - for argument in plan.arguments: - for axis in self._bridge_extent_axes(argument): - matches = ( - f"{self._argument_extent_name(argument, axis)} == {role_names[argument.array.extent_roles[axis]]}" - ) + for parameter in plan.entrypoint.parameters: + if parameter.source_kind != "argument_extent": + continue + argument = self._argument_by_owner(plan, parameter.owner_path) + for extent in parameter.extents: + matches = f"{extent.parameter_name} == {role_names[argument.array.extent_roles[extent.axis]]}" if argument.entrypoint.optional_mode is not OptionalMode.REQUIRED: # An omitted actual has no extent to disagree with. matches = f"(.not. {self._presence_condition(argument)} .or. {matches})" conditions.append(matches) return " .and. ".join(conditions) or None - @staticmethod - def _bridge_extent_axes(argument: ArgumentTransferPlan) -> tuple[int, ...]: - """Return the axes of one argument that only the bridge can evaluate.""" - if argument.array is None: - return () - return tuple(axis for axis, evaluation in enumerate(argument.array.extent_evaluation) if evaluation == "bridge") - - @staticmethod - def _argument_extent_name(argument: ArgumentTransferPlan, axis: int) -> str: - """Return the shared entrypoint ABI name for one declared argument extent.""" - return f"{argument.entrypoint.parameter_name}_declared_extent_{axis}" + def _extent_assignments(self, plan: FunctionPlan, source_kind: str) -> tuple[FortranAssignment, ...]: + """Evaluate each extent one kind of group hands back, from its owner's declared shape.""" + assignments = [] + for parameter in plan.entrypoint.parameters: + if parameter.source_kind != source_kind: + continue + owner = ( + self._argument_by_owner(plan, parameter.owner_path) + if source_kind == "argument_extent" + else self._result_by_owner(plan, parameter.owner_path) + ) + shape = self._array_shape_from_roles(owner.array, plan) + assignments.extend( + FortranAssignment(extent.parameter_name, CodeExpression(f"int({shape[extent.axis]}, c_int64_t)")) + for extent in parameter.extents + ) + return tuple(assignments) # Immediate callback adapters. def _callback_standalone_adapter_procedure( @@ -6315,9 +6265,8 @@ def _direct_array_result_initializers( ): return () shape = list(self._array_result_shape(plan, result)) - for axis, evaluation in enumerate(result.array.extent_evaluation): - if evaluation == "bridge": - shape[axis] = self._declaration_extent_result_name(result, axis) + for axis, evaluated in plan.entrypoint.extent_names(result.owner_path).items(): + shape[axis] = evaluated return (FortranAllocate(f"result_value({', '.join(shape)})"),) def _array_result_depends_on_descriptor( diff --git a/prik/planning/models.py b/prik/planning/models.py index 7b69a6a07..788ba3892 100644 --- a/prik/planning/models.py +++ b/prik/planning/models.py @@ -857,18 +857,29 @@ class BindingFunctionPlan(StageRecord): accepts_keyword_arguments: bool = True +@dataclass +class NativeEntrypointExtentPlan(StageRecord): + """One extent only the bridge can evaluate, and the C-ABI output carrying it.""" + + axis: int + parameter_name: str + + @dataclass class NativeEntrypointParameterPlan(StageRecord): """Order one argument or result parameter group in the shared C ABI. The referenced argument or result entrypoint facet owns the group's exact transport. ``position`` orders groups after any direct function return. + ``extents`` are the outputs an extent group carries, one per axis a + specification function sizes, so neither backend enumerates them again. """ owner_path: str position: int source_kind: str native_position: int | None = None + extents: tuple[NativeEntrypointExtentPlan, ...] = () @dataclass @@ -886,6 +897,15 @@ class NativeEntrypointFunctionPlan(StageRecord): # declaration of ``symbol_name`` cannot collide with a header declaration. collision_adapter_symbol: str | None = None + def extent_names(self, owner_path: str) -> dict[int, str]: + """Return the output name of each bridge-evaluated axis one owner has.""" + return { + extent.axis: extent.parameter_name + for parameter in self.parameters + if parameter.owner_path == owner_path + for extent in parameter.extents + } + @dataclass class BridgeFunctionPlan(StageRecord): diff --git a/prik/planning/planner.py b/prik/planning/planner.py index 469cfc624..69895a38c 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -122,6 +122,7 @@ NativeGeneratedCodeGroupPlan, GeneratedSupportProcedureImplementationOwner, NativeEntrypointArgumentPlan, + NativeEntrypointExtentPlan, NativeEntrypointCallbackPlan, NativeEntrypointFunctionPlan, DirectCABIPlan, @@ -1544,28 +1545,52 @@ def _entrypoint_parameter_plans( ) ) ) - groups.extend( - (result.owner_path, "declaration_extent", None) - for result in results - if result.array is not None and "bridge" in result.array.extent_evaluation - ) - # Only the bridge can evaluate a specification function, so it hands - # back the extent one declares for the binding to check the actual by. - groups.extend( - (argument.owner_path, "argument_extent", None) - for argument in arguments - if argument.array is not None and "bridge" in argument.array.extent_evaluation - ) + extents = WrapperPlanner._entrypoint_extent_groups(arguments, results) + groups.extend((owner, kind, None) for owner, kind in extents) return tuple( NativeEntrypointParameterPlan( owner_path=owner, position=position, source_kind=source_kind, native_position=native_position, + extents=extents.get((owner, source_kind), ()), ) for position, (owner, source_kind, native_position) in enumerate(groups) ) + @staticmethod + def _entrypoint_extent_groups( + arguments: tuple[ArgumentTransferPlan, ...], + results: tuple[NativeEntrypointResultPlan, ...], + ) -> dict[tuple[str, str], tuple[NativeEntrypointExtentPlan, ...]]: + """Return the extents each owner's group hands back, keyed by owner and group kind. + + Only the bridge can evaluate a specification function, so it hands back + each extent one sizes: a result's to allocate by, an argument's for the + binding to check the actual by. + """ + extents: dict[tuple[str, str], tuple[NativeEntrypointExtentPlan, ...]] = {} + for result in results: + if result.array is not None and "bridge" in result.array.extent_evaluation: + extents[(result.owner_path, "declaration_extent")] = WrapperPlanner._bridge_extents( + result.array, f"prik_decl_extent_{result.result_position}" + ) + for argument in arguments: + if argument.array is not None and "bridge" in argument.array.extent_evaluation: + extents[(argument.owner_path, "argument_extent")] = WrapperPlanner._bridge_extents( + argument.array, f"{argument.entrypoint.parameter_name}_declared_extent" + ) + return extents + + @staticmethod + def _bridge_extents(array, prefix: str) -> tuple[NativeEntrypointExtentPlan, ...]: + """Name the output carrying each axis of one array the bridge evaluates.""" + return tuple( + NativeEntrypointExtentPlan(axis=axis, parameter_name=f"{prefix}_{axis}") + for axis, evaluation in enumerate(array.extent_evaluation) + if evaluation == "bridge" + ) + def _entrypoint_result_plans( self, results: tuple[ResultPlan, ...], diff --git a/tests/fortran/arrays/end_to_end/test_declaration_extent_expressions.py b/tests/fortran/arrays/end_to_end/test_declaration_extent_expressions.py index d95e1e461..e3e8a8030 100644 --- a/tests/fortran/arrays/end_to_end/test_declaration_extent_expressions.py +++ b/tests/fortran/arrays/end_to_end/test_declaration_extent_expressions.py @@ -494,3 +494,79 @@ def test_an_actual_is_checked_against_the_extent_a_specification_function_declar ): with pytest.raises(TypeError, match="has incompatible shape at axis 0"): call() + + +EXTENT_BOUNDARY_SOURCE = """ +module extent_boundary + use, intrinsic :: iso_c_binding, only: c_double + implicit none + type :: box + integer :: value = 0 + end type box +contains + pure integer function extent_for(n) + integer, intent(in) :: n + extent_for = n + 1 + end function extent_for + subroutine pair(n, grid, values) + integer, intent(in) :: n + real(c_double), intent(inout) :: grid(n, n) + real(c_double), intent(in) :: values(extent_for(n)) + grid = sum(values) + end subroutine pair + function boxed(n, values) result(out) + integer, intent(in) :: n + real(c_double), intent(in) :: values(extent_for(n)) + type(box) :: out + out%value = size(values) + end function boxed +end module extent_boundary +""" + + +EXTENT_BOUNDARY_CONTRACT = """ +from prik.contracts import Addr, Annotated, Arg, COPY_F, Float64, Int32, ORDER_C, native_call, pure + +class Box: + def __init__(self, *, value: Int32 = ...) -> None: ... + + value: Int32 + +@pure +@native_call([Addr(Arg(0))]) +def extent_for(n: Int32) -> Int32: ... + +@native_call([Addr(Arg(0)), Arg(1), Arg(2)]) +def pair(n: Int32, grid: Annotated[Float64[n, n], ORDER_C, COPY_F], values: Float64[extent_for(n)]) -> None: ... + +@native_call([Addr(Arg(0)), Arg(1)]) +def boxed(n: Int32, values: Float64[extent_for(n)]) -> Box: ... +""" + + +def test_a_rejected_extent_runs_nothing_that_follows_the_call(tmp_path: Path): + """A mismatch ends the call where the extent is found: no copy-back, no result. + + `grid` crosses through a Fortran-order temporary whose copy-back follows a + successful call, and `boxed` returns an object only a call produces. A + rejected call must reach neither, so the caller's array is untouched and + the shape error is what surfaces, not a missing result. + """ + module, _ = _build_inline_pyi_contract_module( + tmp_path, + module_name="extent_boundary", + source_text=EXTENT_BOUNDARY_SOURCE, + contract_text=EXTENT_BOUNDARY_CONTRACT, + ) + + grid = np.full((3, 3), 7.0, order="C") + module.pair(np.int32(3), grid, np.ones(4)) + np.testing.assert_array_equal(grid, np.full((3, 3), 4.0)) + grid = np.full((3, 3), 7.0, order="C") + with pytest.raises(TypeError, match="Argument values has incompatible shape at axis 0"): + module.pair(np.int32(3), grid, np.ones(3)) + np.testing.assert_array_equal(grid, np.full((3, 3), 7.0)) + + assert module.boxed(np.int32(3), np.ones(4)).value == 4 + with pytest.raises(TypeError, match="Argument values has incompatible shape at axis 0"): + module.boxed(np.int32(3), np.ones(3)) From 2798cb2d168c1f1c4526a3db02e6dba33c8c514d Mon Sep 17 00:00:00 2001 From: said Date: Sat, 19 Sep 2026 12:23:04 +0100 Subject: [PATCH 85/96] Spell prototypes in the contract ledger, not while converting source fortran2ir still ran the one NamingPolicy allocation left in semantic conversion: _settle_prototype_contract_names() gave each prototype a Python spelling and rewrote SemanticPrototype.name, the callback annotation's name, and its reference's local_name before export policy ran. It held every use-associated name while doing so -- the model contract imports have since replaced -- so a procedure-local interface was suffixed for a name the contract never binds. Conversion now keeps only identity: a prototype's module, declaring procedure, and native name, and a callback reference to it. Contract-name completion spells prototypes in the module's ledger -- a module's own before any withheld declaration, a procedure-local one after, qualified by its scope, both keeping their declared case -- and gives each callback annotation its prototype's spelling; the printer reads both. A callback's prototype policy was identified by module and name, so two procedures each declaring a `cb` stayed apart only through the spelling the converter had settled. Its identity now includes the declaring scope. The settlement, fortran2ir's NamingPolicy import, and its helper walk over a module's types are deleted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 9 ++ docs/developer/packages/naming.md | 6 +- prik/naming/policy.py | 13 ++- prik/policy/construction.py | 20 ++-- prik/policy/exports.py | 73 ++++++++++++-- prik/printers/pyi.py | 28 +++++- prik/semantics/fortran2ir.py | 95 +------------------ .../test_fortran_callback_semantics.py | 9 +- .../semantics/test_declaration_publication.py | 67 +++++++++++-- 9 files changed, 191 insertions(+), 129 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3c4420f7..44e7ced91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ release tags add a leading `v` to the package version. ## Unreleased +- A prototype's contract spelling is completed in post-IR policy, in the same + ledger as every other name, rather than allocated while Fortran source is + converted. Conversion keeps a prototype's identity -- its module, declaring + procedure, and native name -- and a callback reference to it; completion + spells both, and a callback's generated interface is identified by that + structure rather than by its spelling. A procedure-local interface is no + longer suffixed for a name the module reaches through `use` but whose + contract never binds it (`first_cb` rather than `first_cb_2`). + - An array argument sized by a specification function is checked against the extent its dummy declares. The binding checks every other declared extent, but only the Fortran bridge can evaluate a specification function, so a diff --git a/docs/developer/packages/naming.md b/docs/developer/packages/naming.md index 1de087d34..16d4a1075 100644 --- a/docs/developer/packages/naming.md +++ b/docs/developer/packages/naming.md @@ -60,8 +60,10 @@ prik/naming/ `NamingPolicy` retains contract-namespace reservations for one policy completion operation. Post-IR policy records the selected spelling on semantic owners; contract emission and class-surface construction read that result and -do not create their own reservation ledgers. Publication is separate: a -withheld declaration still has a contract spelling so annotations can name it. +do not create their own reservation ledgers, and neither does semantic +conversion: a prototype, too, is spelled in that ledger, keeping the case it is +declared in. Publication is separate: a withheld declaration still has a +contract spelling so annotations can name it. `NativeSymbolNames` is stateless: the same owner, preferred spelling, and limit always produce the same result. diff --git a/prik/naming/policy.py b/prik/naming/policy.py index bf9573eea..d1d7f1452 100644 --- a/prik/naming/policy.py +++ b/prik/naming/policy.py @@ -119,9 +119,18 @@ def reserve_public_name( *, category: str, owner: object | None = None, + preserve_case: bool | None = None, ) -> str: - """Reserve one public Python name within its namespace.""" - normalized = normalize_public_name(raw_name, preserve_case=self.preserve_case, category=category) + """Reserve one public Python name within its namespace. + + ``preserve_case`` overrides the policy's rule for a name written as it + is declared wherever it appears, such as a prototype's. + """ + normalized = normalize_public_name( + raw_name, + preserve_case=self.preserve_case if preserve_case is None else preserve_case, + category=category, + ) raw_text = str(raw_name) namespace_key = tuple(str(part) for part in namespace) namespace_text = ".".join(namespace_key) or "" diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 6b17b1b5b..d2af3a3f2 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -1398,18 +1398,24 @@ def build_callback_handoff_policy( blockers.extend(_callback_result_blockers(return_type, result)) # Complete the shared exact signature after argument and result ABI facts exist. prototype_ref = semantic_type.metadata.get(models.PROTOTYPE_REF_METADATA) - source_name = prototype_ref.get("name") if isinstance(prototype_ref, dict) else None - local_name = prototype_ref.get("local_name") if isinstance(prototype_ref, dict) else None - origin_module = prototype_ref.get("origin_module") if isinstance(prototype_ref, dict) else None + reference = prototype_ref if isinstance(prototype_ref, dict) else {} + source_name = reference.get("name") if not isinstance(source_name, str) or not source_name: blockers.append("callback argument requires a resolved named prototype") source_name = semantic_type.name - if not isinstance(local_name, str) or not local_name: - local_name = semantic_type.name + # A prototype is its declaring module and scope with the name that scope + # gives it; the contract spelling only names it. + identity = ".".join( + (reference.get("origin_module") or owner_path, *reference.get("declaring_scope", ()), source_name) + ) + written = semantic_type.metadata.get(models.CONTRACT_NAME_METADATA) + if not isinstance(written, str) or not written: + blockers.append("callback prototype has no completed contract spelling") + written = semantic_type.name prototype = _procedure_prototype_policy( owner_path=owner_path, - name=local_name, - identity=f"{origin_module or owner_path}.{source_name}", + name=written, + identity=identity, pure=_prototype_metadata_is_pure(semantic_type.metadata.get("prototype_metadata")), source_language=semantic_type.metadata.get("prototype_source_language"), native_abi=semantic_type.metadata.get("prototype_native_abi"), diff --git a/prik/policy/exports.py b/prik/policy/exports.py index db0ca8a69..db940e21f 100644 --- a/prik/policy/exports.py +++ b/prik/policy/exports.py @@ -303,15 +303,9 @@ def _complete_contract_names( imported = _complete_imported_names(module, naming, contract_named=contract_named) - for prototype in module.prototypes: - completed = str(prototype.name) - naming.hold_completed_public_name( - _declaring_namespace(module, prototype), - completed, - category="function", - owner=f"prototype {prototype.native_name or prototype.name}", - ) - prototype.metadata[models.CONTRACT_NAME_METADATA] = completed + # A module's own prototype declares the name other modules import, so it + # is spelled before any withheld declaration takes a name. + _complete_prototype_contract_names(module, naming, (item for item in module.prototypes if not item.declaring_scope)) # A withheld declaration is written in the file being completed, whatever # module declared it natively: a generic's inherited specifics are carried @@ -326,6 +320,11 @@ def _complete_contract_names( owner=f"{_owner_category(owner)} {owner.name}", ) + # A block inside a procedure is that procedure's alone, spelled after the + # module's own declarations and qualified by its scope. + _complete_prototype_contract_names(module, naming, (item for item in module.prototypes if item.declaring_scope)) + _complete_prototype_reference_names(module) + for semantic_class in module.classes: _complete_class_member_contract_names( semantic_class, @@ -339,6 +338,56 @@ def _complete_contract_names( _complete_overload_target_contract_names(module, preserve_case=preserve_case) +def _complete_prototype_contract_names(module: models.SemanticModule, naming: NamingPolicy, prototypes) -> None: + """Spell each prototype in the module's contract ledger, as it is declared. + + A prototype is identified by its declaring scope and the name that scope + gives it, and two contained procedures may give theirs the same name, so a + procedure-local one suggests its scope with its name. The spelling keeps + the case it is declared in, wherever the prototype is written. + """ + for prototype in prototypes: + suggestion = "_".join((*prototype.declaring_scope, str(prototype.native_name or prototype.name))) + prototype.metadata[models.CONTRACT_NAME_METADATA] = naming.reserve_public_name( + _declaring_namespace(module, prototype), + suggestion, + category="function", + owner=f"prototype {suggestion}", + preserve_case=True, + ) + + +def _complete_prototype_reference_names(module: models.SemanticModule) -> None: + """Spell each callback annotation the way the contract names its prototype. + + A prototype the module declares is named by its completed spelling. One it + imports keeps the name this module binds it under, which a prototype keeps + wherever it is written. + """ + declared = { + _prototype_identity( + prototype.origin.native_scope or module.name, + prototype.declaring_scope, + prototype.native_name or prototype.name, + ): models.completed_contract_name(prototype) + for prototype in module.prototypes + } + for semantic_type in models._module_semantic_types(module): + reference = semantic_type.metadata.get(models.PROTOTYPE_REF_METADATA) + if not isinstance(reference, dict): + continue + identity = _prototype_identity( + reference.get("origin_module", ""), reference.get("declaring_scope", ()), reference.get("name", "") + ) + written = str(reference.get("local_name") or semantic_type.name) + semantic_type.metadata[models.CONTRACT_NAME_METADATA] = declared.get(identity, written) + + +def _prototype_identity(module_name: object, scope, name: object) -> tuple[str, tuple[str, ...], str]: + """Return one prototype's ``(module, declaring scope, name)`` identity.""" + return str(module_name).casefold(), tuple(str(part) for part in scope), str(name).casefold() + + def _own_export(module: models.SemanticModule, owner) -> tuple[tuple[str, ...], str] | None: """Return the namespace and spelling one declaration is published under at home. @@ -690,7 +739,11 @@ def _specific_identity(function: models.SemanticFunction) -> tuple[str, str] | N def contract_names_by_source(module: models.SemanticModule) -> dict[str, str]: """Return source spellings mapped to the names this contract declares.""" names = {str(owner.name): models.completed_contract_name(owner) for owner in _module_export_owners(module)} - names.update((str(prototype.name), models.completed_contract_name(prototype)) for prototype in module.prototypes) + names.update( + (str(prototype.name), models.completed_contract_name(prototype)) + for prototype in module.prototypes + if not prototype.declaring_scope + ) names.update( (str(reexport.local_name), str(reexport.python_name or reexport.local_name)) for reexport in module.reexports ) diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index f390a426d..860aadec5 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -220,7 +220,7 @@ def _visit_SemanticType( # with the import already emitted for it. text = str(unresolved_interface) elif PROTOTYPE_REF_METADATA in semantic_type.metadata: - text = semantic_type.name + text = self._prototype_reference_name(semantic_type, context) elif array_descriptor is not None: wrapper = "Allocatable" if array_descriptor == "allocatable" else "Pointer" text = f"{context.contract(wrapper)}[{self._visit(native_array_data_type(semantic_type), context)}]" @@ -293,7 +293,7 @@ def _visit_SemanticPrototype( decorators.append(f"@{context.contract('pure')}") decorators.append(f"@{context.contract('prototype')}") return self._emit_callable( - name=prototype.name, + name=self._prototype_name(prototype, context), arguments=arguments, return_type=self._visit(return_type, context), decorator="\n".join(decorators) + "\n", @@ -570,7 +570,11 @@ def _module_exported_names( # A prototype the contract needs for typing is not thereby published: # a private one names a signature the module keeps to itself, and the # annotations referring to it still resolve inside this file. - names.extend(str(prototype.name) for prototype in module.prototypes if not self._is_private(prototype)) + names.extend( + self._prototype_name(prototype, context) + for prototype in module.prototypes + if not self._is_private(prototype) + ) for variable in self._contract_items(module.variables): if getattr(variable, "visibility", "public") != "private": names.append(self._module_variable_name(variable, context)) @@ -1877,6 +1881,24 @@ def _reexport_name(reexport: SemanticReexport, context: _PyiEmissionContext) -> ) return str(reexport.python_name) + @staticmethod + def _prototype_name(prototype: SemanticPrototype, context: _PyiEmissionContext) -> str: + """Return the spelling a prototype is declared under in the contract.""" + return completed_contract_name(prototype) if context.normalize_public_names else str(prototype.name) + + @staticmethod + def _prototype_reference_name(semantic_type: SemanticType, context: _PyiEmissionContext) -> str: + """Return the spelling a callback annotation names its prototype by.""" + if not context.normalize_public_names: + return str(semantic_type.name) + completed = semantic_type.metadata.get(CONTRACT_NAME_METADATA) + if completed is None: + raise ValueError( + f"Contract name for prototype reference {semantic_type.name!r} is incomplete; " + "run complete_python_export_policy before emission" + ) + return str(completed) + @staticmethod def _class_name(cls: SemanticClass, context: _PyiEmissionContext) -> str: """Return the Python-visible class name to write in the contract.""" diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 89e594ecd..b27b71c8a 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -65,7 +65,6 @@ SEMANTIC_SCALAR_TYPE_NAMES, is_boolean_semantic_type_name, ) -from prik.naming import NamingPolicy from prik.utilities.visitor import ClassVisitor from prik.semantics.models import ( @@ -1030,8 +1029,7 @@ def _callback_semantic_type( "origin_module": prototype_module, # The scope declaring the interface completes its identity: # two procedures may each declare a `cb` meaning different - # signatures, and the contract spelling is settled from this - # identity once, then read here. + # signatures, which contract-name completion spells apart. "declaring_scope": tuple(declaring_scope), }, "native_callback_kind": signature.kind, @@ -1159,94 +1157,6 @@ def _record_prototype_argument_intent( if intent is not None: argument.origin.metadata[PROTOTYPE_INTENT_METADATA] = intent - def _settle_prototype_contract_names( - self, - module: FortranModule, - index: dict[str, FortranModule], - prototypes: list[SemanticPrototype], - functions: list[SemanticFunction], - classes: list[SemanticClass], - ) -> None: - """Give each prototype identity one contract spelling, read by both sides. - - A prototype's identity is its declaring scope and the name that scope - gives it, which two contained procedures may spell the same. The Python - spelling is therefore allocated here, once, against the names this - module already holds -- a procedure-local block suggests its scope and - name, and the allocator settles any collision with a module-level - declaration or another scope. Every annotation naming the prototype then - reads the settled spelling rather than rebuilding one. - """ - if not prototypes: - return - # A prototype is written as it is declared, so the spelling is kept and - # only a collision moves one aside; case folding belongs to the public - # names a build publishes, which a prototype is not. - naming = NamingPolicy(preserve_case=True) - # A module's own block declares the name another module imports, so it - # keeps it; every other name the contract binds is held first so no - # prototype can be handed a spelling that already belongs to one. A - # use-associated name binds in this module too, and the contract writes - # an import for it, so it is held alongside the declared names. - module_scope = { - str(prototype.native_name or prototype.name).casefold() - for prototype in prototypes - if not prototype.declaring_scope - } - held = self._module_declared_names(module) | { - name.casefold() for name in self._use_associated_names(module, index) - } - for name in sorted(held): - if name in module_scope: - continue - naming.reserve_public_name((), name, category="function", owner=("declared", name)) - settled: dict[tuple[str, tuple[str, ...], str], str] = {} - for prototype in sorted(prototypes, key=lambda item: bool(item.declaring_scope)): - identity = ( - module.name.casefold(), - tuple(prototype.declaring_scope), - str(prototype.native_name or prototype.name).casefold(), - ) - suggestion = "_".join((*prototype.declaring_scope, str(prototype.native_name or prototype.name))) - prototype.name = naming.reserve_public_name( - (), - suggestion, - category="function", - owner=("prototype", identity), - ) - settled[identity] = prototype.name - for semantic_type in self._module_semantic_types(prototypes, functions, classes): - identity = self._prototype_reference_identity(semantic_type) - contract_name = settled.get(identity) if identity is not None else None - if contract_name is None: - continue - semantic_type.name = contract_name - semantic_type.metadata[PROTOTYPE_REF_METADATA]["local_name"] = contract_name - - @classmethod - def _module_semantic_types( - cls, - prototypes: list[SemanticPrototype], - functions: list[SemanticFunction], - classes: list[SemanticClass], - ) -> Iterable[SemanticType]: - """Yield every semantic type one module's declarations carry.""" - callables: list[SemanticFunction] = [*prototypes, *functions] - pending = list(classes) - while pending: - declaration = pending.pop() - callables.extend(declaration.methods) - pending.extend(declaration.classes) - for field in declaration.fields: - if field.semantic_type is not None: - yield field.semantic_type - for callable_item in callables: - for argument in (*callable_item.arguments, *callable_item.locals): - if argument.semantic_type is not None: - yield argument.semantic_type - if callable_item.return_type is not None: - yield callable_item.return_type - @staticmethod def _prototype_reference_identity( semantic_type: SemanticType | None, @@ -1293,7 +1203,7 @@ def _module_prototypes( for signature in interface.procedures: declared = interface.name if interface.name and len(interface.procedures) == 1 else signature.name # Identity is the declaring scope with the name that scope - # gives; the contract spelling for it is settled afterwards. + # gives; contract-name completion spells it. identity = (module.name.casefold(), scope, declared.casefold()) if not (interface.abstract or identity in referenced or declared.casefold() in called): continue @@ -1698,7 +1608,6 @@ def _visit_FortranModule( prototypes=prototypes, ), ) - self._settle_prototype_contract_names(module, index, prototypes, semantic_functions, semantic_classes) return SemanticModule( name=module.name, functions=semantic_functions, diff --git a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py index 034c0db7a..78576312f 100644 --- a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py +++ b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py @@ -1,9 +1,11 @@ """Tests split by stable ownership concept from `test_compile_time_values.py`.""" from prik.parsers.fortran import parse_fortran_project +from prik.policy.exports import complete_python_export_policy from prik.printers import emit_module from prik.semantics.fortran2ir import FortranToIRConverter from prik.semantics.models import ( + CONTRACT_NAME_METADATA, EXTERNAL_TYPE_REF_METADATA, PROTOTYPE_REF_METADATA, UNRESOLVED_PROCEDURE_INTERFACE_METADATA, @@ -96,9 +98,12 @@ def test_dummy_procedure_interfaces_become_complete_callable_contracts(): explicit_callback = get_function(module, "explicit_case").arguments[0].semantic_type # A block written inside a procedure names a signature only that procedure - # can reach, so its contract identity is qualified by the owning scope. - assert explicit_callback.name == "explicit_case_callback" + # can reach, so its identity is qualified by the owning scope, and so is the + # contract spelling completion gives it. assert explicit_callback.metadata["prototype_ref"]["name"] == "callback" + assert explicit_callback.metadata["prototype_ref"]["declaring_scope"] == ("explicit_case",) + complete_python_export_policy(module) + assert explicit_callback.metadata[CONTRACT_NAME_METADATA] == "explicit_case_callback" assert [argument.name for argument in explicit_callback.metadata["arguments"]] == ["Int32"] assert explicit_callback.metadata["return"].name == "Int32" diff --git a/tests/fortran/modules/semantics/test_declaration_publication.py b/tests/fortran/modules/semantics/test_declaration_publication.py index b96541463..5bdc35672 100644 --- a/tests/fortran/modules/semantics/test_declaration_publication.py +++ b/tests/fortran/modules/semantics/test_declaration_publication.py @@ -11,6 +11,7 @@ from prik.parsers.fortran import parse_fortran_file, parse_fortran_project from prik.printers.pyi import PyiPrinter +from prik.semantics.models import CONTRACT_NAME_METADATA, completed_contract_name from prik.policy.contract_imports import complete_contract_imports from prik.policy.exports import complete_python_export_policy from prik.semantics.fortran2ir import fortran_file_to_semantic_modules, fortran_project_to_semantic_modules @@ -102,7 +103,7 @@ def test_two_procedures_may_name_different_interfaces_the_same_way(tmp_path: Pat """A block inside a procedure is that procedure's, so each keeps its own.""" module = _module(LOCAL_INTERFACE_SOURCE, tmp_path) - assert [(item.name, item.native_name, item.visibility) for item in module.prototypes] == [ + assert [(completed_contract_name(item), item.native_name, item.visibility) for item in module.prototypes] == [ ("first_cb", "cb", "private"), ("second_cb", "cb", "private"), ] @@ -117,7 +118,7 @@ def test_two_procedures_may_name_different_interfaces_the_same_way(tmp_path: Pat def test_a_procedure_local_interface_is_never_a_module_publication(tmp_path: Path): """A `use` of the module cannot reach it, so the contract does not publish it.""" module = _module(LOCAL_INTERFACE_SOURCE, tmp_path) - contract = PyiPrinter().emit(module) + contract = PyiPrinter(normalize_public_names=True).emit(module) assert "def first_cb(" in contract assert "def second_cb(" in contract @@ -183,7 +184,7 @@ def _callback_annotations(module) -> dict[str, tuple[str, str]]: """Return each callback argument's contract name and first argument type.""" return { f"{function.name}.{argument.name}": ( - argument.semantic_type.name, + argument.semantic_type.metadata[CONTRACT_NAME_METADATA], argument.semantic_type.metadata["arguments"][0].name, ) for function in module.functions @@ -205,7 +206,7 @@ def test_a_prototype_is_identified_by_its_scope_rather_than_its_spelling(tmp_pat assert identities == [("first_cb", (), "public"), ("cb", ("first",), "private")] # The module's own block keeps the spelling another module imports it by. - names = [item.name for item in module.prototypes] + names = [completed_contract_name(item) for item in module.prototypes] assert names[0] == "first_cb" assert names[1] != "first_cb" @@ -218,7 +219,7 @@ def test_scopes_whose_joined_spellings_collide_keep_distinct_contract_names(tmp_ """`a_b` declaring `c` and `a` declaring `b_c` are different prototypes.""" module = _module(JOINED_COLLISION_SOURCE, tmp_path) - names = [item.name for item in module.prototypes] + names = [completed_contract_name(item) for item in module.prototypes] assert len(set(names)) == 2 annotations = _callback_annotations(module) @@ -229,8 +230,8 @@ def test_scopes_whose_joined_spellings_collide_keep_distinct_contract_names(tmp_ def test_a_contract_writes_one_prototype_for_each_scope(tmp_path: Path): """Both prototypes are written, and only the module's own is published.""" module = _module(MODULE_AND_LOCAL_SOURCE, tmp_path) - contract = PyiPrinter().emit(module) - local_name = module.prototypes[1].name + contract = PyiPrinter(normalize_public_names=True).emit(module) + local_name = completed_contract_name(module.prototypes[1]) assert "def first_cb(\n x: Int32[()]\n) -> None: ..." in contract assert f"def {local_name}(\n x: Float32[()]\n) -> None: ..." in contract @@ -277,10 +278,56 @@ def test_a_prototype_does_not_take_a_name_the_module_imports(tmp_path: Path): complete_python_export_policy(module) assert [(item.native_name, item.declaring_scope) for item in module.prototypes] == [("cb", ("first",))] - assert module.prototypes[0].name != "first_cb" + spelled = completed_contract_name(module.prototypes[0]) + assert spelled != "first_cb" complete_contract_imports([module]) contract = PyiPrinter(normalize_public_names=True).emit(module) assert "from .helper_mod import first_cb" in contract - assert f"def {module.prototypes[0].name}(" in contract - assert f"f: {module.prototypes[0].name}" in contract + assert f"def {spelled}(" in contract + assert f"f: {spelled}" in contract + + +UNBOUND_USE_SOURCE = """\ +module helper_mod + implicit none + integer :: first_cb = 7 +end module helper_mod + +module m_mod + use helper_mod, only : first_cb + implicit none + private + public :: first +contains + subroutine first(f) + abstract interface + subroutine cb(x) + real :: x + end subroutine + end interface + procedure(cb) :: f + call f(real(first_cb)) + end subroutine first +end module m_mod +""" + + +def test_a_prototype_is_spelled_against_what_the_contract_binds(tmp_path: Path): + """A name the module reaches but its contract never binds does not move a prototype. + + `m_mod` uses `first_cb` only in executable code and publishes nothing but + `first`, so its contract imports no `first_cb`. Spelling prototypes while + converting source held every use-associated name and suffixed this one. + """ + (tmp_path / "project.f90").write_text(UNBOUND_USE_SOURCE, encoding="utf-8") + module = { + module.name: module for module in fortran_project_to_semantic_modules(parse_fortran_project(str(tmp_path))) + }["m_mod"] + complete_python_export_policy(module) + complete_contract_imports([module]) + contract = PyiPrinter(normalize_public_names=True).emit(module) + + assert completed_contract_name(module.prototypes[0]) == "first_cb" + assert "from .helper_mod" not in contract + assert "f: first_cb\n" in contract From 8f5949355c9f618cece6cbf7106931dfceadb583 Mon Sep 17 00:00:00 2001 From: said Date: Sat, 19 Sep 2026 12:47:10 +0100 Subject: [PATCH 86/96] Let a generic own its export decision ProcedureOverloadSet carries its own metadata, yet its Python export decision was stored on its first specific, and four places encoded that: _owner_metadata() in export policy, _declaration_metadata() in the build pipeline, _entry_exports() in completion, and build_module_overload_policy reading completed_python_exports(first). The generic was the conceptual owner and an arbitrary specific the physical one, and a generic without specifics had nowhere to keep a decision -- the adapter returned a temporary {} that was discarded. The decision now lives on the generic, every reader reads declaration.metadata as it does for any other declaration, and the three adapters and the empty-generic special case are deleted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 5 +++ prik/pipeline/build.py | 24 ++------------ prik/policy/completion.py | 13 +------- prik/policy/construction.py | 9 ++---- prik/policy/exports.py | 11 ++----- .../policy/test_generic_policy.py | 32 +++++++++++++++++++ .../test_contract_publication_round_trip.py | 6 ++-- 7 files changed, 47 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44e7ced91..b13f31a6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ release tags add a leading `v` to the package version. ## Unreleased +- A generic owns its Python export decision the way every other declaration + does, in its own metadata. It was stored on the generic's first specific, + so three readers each reached through that specific and a generic without + specifics could not record a decision at all. + - A prototype's contract spelling is completed in post-IR policy, in the same ledger as every other name, rather than allocated while Fortran source is converted. Conversion keeps a prototype's identity -- its module, declaring diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index be4e8133f..699ea0d30 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -62,13 +62,11 @@ PYTHON_EXPORTS_PREPARED_METADATA, RESOLVED_FUNCTION_WRAPPER_POLICY_METADATA, ProcedureOverloadSet, - SemanticClass, SemanticFunction, SemanticImport, SemanticModule, SemanticPrototype, SemanticReexport, - SemanticVariable, _module_semantic_types, ) from prik.semantics.native_contract import NATIVE_CONTRACT_PREPARED_METADATA, validate_pyi_native_contract @@ -2311,32 +2309,14 @@ def _module_declarations(module: SemanticModule) -> tuple[object, ...]: return (*module.variables, *module.functions, *module.overload_sets, *module.classes) -def _declaration_metadata(declaration: object) -> dict[str, object]: - """Return the mutable metadata dictionary for one supported declaration. - - Overload sets use their first candidate's metadata because that is where - their shared export projection is stored. Unsupported objects raise - ``TypeError`` rather than silently lose metadata. - """ - if isinstance(declaration, ProcedureOverloadSet): - if not declaration.procedures: - return {} - return declaration.procedures[0].metadata - if isinstance(declaration, SemanticVariable | SemanticFunction | SemanticClass): - return declaration.metadata - raise TypeError(f"Unsupported semantic declaration: {type(declaration).__name__}") - - def _declaration_exports(declaration: object) -> list[dict[str, object]]: """Return and initialize the declaration's mutable Python export list.""" - metadata = _declaration_metadata(declaration) - return metadata.setdefault(PYTHON_EXPORTS_METADATA, []) + return declaration.metadata.setdefault(PYTHON_EXPORTS_METADATA, []) def _set_declaration_exports(declaration: object, exports: list[dict[str, object]]) -> None: """Replace one declaration's stored Python export projection in place.""" - metadata = _declaration_metadata(declaration) - metadata[PYTHON_EXPORTS_METADATA] = exports + declaration.metadata[PYTHON_EXPORTS_METADATA] = exports def _apply_source_python_exports(modules: list[SemanticModule]) -> None: diff --git a/prik/policy/completion.py b/prik/policy/completion.py index 417d29111..c6e8ec3e5 100644 --- a/prik/policy/completion.py +++ b/prik/policy/completion.py @@ -289,18 +289,7 @@ def _is_entry_export_reachable(declaration: object) -> bool: """Keep private declarations and public declarations selected by entry exports.""" if getattr(declaration, "visibility", "public") == "private": return True - return bool(_entry_exports(declaration)) - - -def _entry_exports(declaration: object) -> object: - """Return a declaration's entry-export metadata, with overloads using their first procedure.""" - if isinstance(declaration, models.ProcedureOverloadSet): - if not declaration.procedures: - return () - return declaration.procedures[0].metadata.get(models.PYTHON_EXPORTS_METADATA, ()) - if isinstance(declaration, models.SemanticVariable | models.SemanticFunction | models.SemanticClass): - return declaration.metadata.get(models.PYTHON_EXPORTS_METADATA, ()) - raise TypeError(f"Unsupported semantic declaration: {type(declaration).__name__}") + return bool(declaration.metadata.get(models.PYTHON_EXPORTS_METADATA, ())) def _complete_ownership_policies( diff --git a/prik/policy/construction.py b/prik/policy/construction.py index d2af3a3f2..47030e909 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -781,16 +781,13 @@ def build_module_overload_policy( overload: models.ProcedureOverloadSet, ) -> OverloadPolicy: """Complete the stable owner and Python exports for one module generic.""" - if not overload.procedures: - return _overload_policy(overload.native_scope or module.name, overload, module_generic=True) - first = overload.procedures[0] # 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) + first_scope = overload.procedures[0].origin.native_scope if overload.procedures else None return _overload_policy( - native_scope, + str(overload.native_scope or first_scope or module.name), overload, - python_exports=completed_python_exports(first), + python_exports=completed_python_exports(overload), module_generic=True, ) diff --git a/prik/policy/exports.py b/prik/policy/exports.py index db940e21f..7d98fedec 100644 --- a/prik/policy/exports.py +++ b/prik/policy/exports.py @@ -76,7 +76,7 @@ def complete_python_export_policy( preserve_case=contract_named or preserves_source_case(module.origin.source_language), ) for owner in _module_export_owners(module): - metadata = _owner_metadata(owner) + metadata = owner.metadata if getattr(owner, "visibility", "public") == "private" or ( stated is not None and str(owner.name) not in stated ): @@ -396,7 +396,7 @@ def _own_export(module: models.SemanticModule, owner) -> tuple[tuple[str, ...], it, which names nothing in its own contract. """ home = {(), _declaring_namespace(module, owner)} - for export in _owner_metadata(owner).get(models.PYTHON_EXPORTS_METADATA, ()) or (): + for export in owner.metadata.get(models.PYTHON_EXPORTS_METADATA, ()) or (): if not isinstance(export, dict) or export.get("name") is None: continue namespace = tuple(part.casefold() for part in export_namespace(export)) @@ -750,13 +750,6 @@ def contract_names_by_source(module: models.SemanticModule) -> dict[str, str]: return names -def _owner_metadata(owner) -> dict[str, object]: - """Return the metadata mapping that owns one export policy.""" - if isinstance(owner, models.ProcedureOverloadSet): - return owner.procedures[0].metadata if owner.procedures else {} - return owner.metadata - - def _owner_category(owner) -> str: """Return the public-name category used for collision diagnostics.""" if isinstance(owner, models.SemanticVariable): diff --git a/tests/fortran/generic_interfaces/policy/test_generic_policy.py b/tests/fortran/generic_interfaces/policy/test_generic_policy.py index fffeb263f..c3d550294 100644 --- a/tests/fortran/generic_interfaces/policy/test_generic_policy.py +++ b/tests/fortran/generic_interfaces/policy/test_generic_policy.py @@ -51,3 +51,35 @@ def convert(value: Float64) -> Float64: ... for procedure in module.overload_sets[0].procedures ] assert [policy.native_name for policy in policies] == ["convert_integer", "convert"] + + +def test_a_generic_owns_its_export_decision_like_every_declaration(): + """The decision lives on the generic, not on whichever specific came first. + + Keeping it on the first candidate made a generic without candidates unable + to record one at all, and left the generic's own metadata empty. + """ + from prik.policy.exports import complete_python_export_policy + from prik.semantics.models import PYTHON_EXPORTS_METADATA, ProcedureOverloadSet, SemanticModule + + module = parse_pyi_text( + """ +from prik.contracts import Addr, Arg, Int32, native_call, overload + +@native_call([Addr(Arg(0))]) +def convert_i(x: Int32) -> Int32: ... + +@overload("convert_i") +def convert(x: Int32) -> Int32: ... +""", + module_name="owned_generic", + ) + complete_python_export_policy(module) + generic = module.overload_sets[0] + + assert generic.metadata[PYTHON_EXPORTS_METADATA] == [{"namespace": (), "name": "convert"}] + assert module.functions[0].metadata[PYTHON_EXPORTS_METADATA] == [{"namespace": (), "name": "convert_i"}] + + empty = SemanticModule(name="placeholder", overload_sets=[ProcedureOverloadSet(name="later", procedures=[])]) + complete_python_export_policy(empty) + assert empty.overload_sets[0].metadata[PYTHON_EXPORTS_METADATA] == [{"namespace": (), "name": "later"}] diff --git a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py index b37d0a832..d7dc0c972 100644 --- a/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py +++ b/tests/fortran/infrastructure/semantic_pyi/pipeline/test_contract_publication_round_trip.py @@ -15,7 +15,6 @@ from prik.printers.pyi import PyiPrinter from prik.semantics.models import ( PYTHON_EXPORTS_METADATA, - ProcedureOverloadSet, SemanticArgument, SemanticClass, SemanticFunction, @@ -90,10 +89,9 @@ def test_reloading_a_contract_does_not_publish_what_all_leaves_out(generated_con assert [item.name for item in reloaded.prototypes] == ["cb"] assert [item.name for item in reloaded.overload_sets] == ["hidden_generic"] + # A generic owns its decision the way every other declaration does. published = { - str(owner.name): (owner.procedures[0] if isinstance(owner, ProcedureOverloadSet) else owner).metadata.get( - PYTHON_EXPORTS_METADATA - ) + str(owner.name): owner.metadata.get(PYTHON_EXPORTS_METADATA) for owner in (*reloaded.functions, *reloaded.overload_sets) } assert published["run"] == [{"namespace": (), "name": "run"}] From 16d6c232b8c55c2adbd1773f00195ca17e7d00c9 Mon Sep 17 00:00:00 2001 From: said Date: Sat, 19 Sep 2026 13:33:56 +0100 Subject: [PATCH 87/96] Remove the retired Boolean-array copy Policy stopped selecting a native-kind copy for logical arrays once a NumPy integer of each element's own width became their buffer: _array_logical_argument_abi() returns only NOT_APPLICABLE or C_BOOL_VIEW, with both copy flags false, and _array_writeback_abi() only NOT_APPLICABLE or NATIVE_ARRAY. Everything that lowered or validated the other answers stayed, reachable from no completed plan: - ArrayLogicalABI.NATIVE_KIND_COPY and its reason string; - the array_copy_in / array_copy_out policy and plan fields, their projection, and their consistency diagnostics; - the array writeback ABI -- enum, policy and plan field, producer, and validator -- whose only distinguishing value was never produced; - the bridge's logical-array copy initializers and finalizers, the exact-kind declarations, the low-bit writeback, and their helpers, plus _array_native_argument_expression(), which collapses into the boundary expression it wrapped. A test that rejected an edited plan restoring the low-bit writeback pinned behavior that no longer exists and is removed; the Boolean writeback test keeps its invariant that the bridge passes the array straight through. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 7 + prik/codegen/fortran/bridge.py | 209 +----------------- prik/pipeline/wrapper.py | 44 +--- prik/planning/models.py | 6 - prik/planning/planner.py | 5 - prik/policy/construction.py | 94 ++------ prik/policy/models.py | 15 -- .../codegen/test_array_output_identity.py | 19 +- 8 files changed, 25 insertions(+), 374 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b13f31a6e..3fbdad070 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ release tags add a leading `v` to the package version. ## Unreleased +- The retired Boolean-array copy is removed. Policy stopped selecting a + native-kind copy or a post-call low-bit normalization for logical arrays + once a NumPy integer of the element's own width became their buffer, but + the `NATIVE_KIND_COPY` array ABI, the `array_copy_in`/`array_copy_out` + plan fields, the array writeback ABI, and the bridge code lowering them + remained, reachable from no completed plan. + - A generic owns its Python export decision the way every other declaration does, in its own metadata. It was stored on the generic's first specific, so three readers each reached through that specific and a generic without diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index fb5bfaf16..fc8f47e12 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -26,8 +26,6 @@ from prik.policy.models import ( ArgumentHandoffMode, ArrayEntrypointABI, - ArrayLogicalABI, - ArrayWritebackABI, BridgeDataAction, CallbackABIKind, CallbackResultAction, @@ -645,8 +643,6 @@ def _visit_FunctionPlan( *self._derived_pointer_call_initializers(plan), *function_body, *self._logical_scalar_argument_finalizers(plan), - *self._logical_array_argument_finalizers(plan), - *self._array_writeback_finalizers(plan), *self._derived_pointer_call_finalizers(plan), *self._required_descriptor_finalizers(plan), *self._string_value_finalizers(plan), @@ -696,7 +692,6 @@ def _visit_FunctionPlan( *self._logical_scalar_argument_initializers(plan), *self._opaque_address_initializers(plan), *self._array_initializers(plan), - *self._logical_array_argument_initializers(plan), *self._raw_array_address_initializers(plan), *self._string_value_initializers(plan), *self._string_address_initializers(plan), @@ -4662,7 +4657,7 @@ def _native_argument_expression(self, plan: ArgumentTransferPlan) -> str: return f"{name}_call_pointer" return name if plan.entrypoint.handoff_mode is ArgumentHandoffMode.ARRAY_BUFFER: - return self._array_native_argument_expression(plan) + return self._array_boundary_argument_expression(plan) if plan.entrypoint.handoff_mode is ArgumentHandoffMode.NATIVE_DESCRIPTOR: handle = plan.native_array_handle if handle is not None and handle.handoff.abi is NativeDescriptorHandoffABI.FORTRAN_OWNER: @@ -4770,16 +4765,6 @@ def _prepare_present_representation_copy( plan: ArgumentTransferPlan, ) -> tuple[FortranCall | FortranAssignment | FortranIf, ...]: """Copy only when completed policy requires a different native representation.""" - if plan.array_logical_abi is ArrayLogicalABI.NATIVE_KIND_COPY: - nodes: list[FortranCall | FortranAssignment | FortranIf] = list(self._array_pointer_initializer_nodes(plan)) - if plan.array_copy_in: - nodes.append( - FortranAssignment( - self._logical_array_native_name(plan), - CodeExpression(self._array_boundary_argument_expression(plan)), - ) - ) - return tuple(nodes) if plan.scalar_logical_abi is ScalarLogicalABI.NATIVE_KIND_COPY: name = plan.entrypoint.parameter_name return (FortranAssignment(f"{name}_native", CodeExpression(name)),) @@ -5043,194 +5028,8 @@ def _array_declarations(self, plan: FunctionPlan) -> tuple[FortranDeclaration, . ("pointer", self._array_dimension_attribute(array.rank)), ) ) - if argument.array_logical_abi is ArrayLogicalABI.NATIVE_KIND_COPY: - if not argument.array_native_type: - raise ValueError(f"Logical array {argument.owner_path!r} has no native type spelling") - declarations.append( - FortranDeclaration( - self._logical_array_native_name(argument), - argument.array_native_type, - (self._logical_array_dimension_attribute(argument),), - ) - ) - if argument.array_writeback_abi is ArrayWritebackABI.LOGICAL_LOW_BIT_INT8: - declarations.append( - FortranDeclaration( - self._logical_array_byte_pointer_name(argument), - self._logical_array_integer_type(argument.semantic_type_name), - ("pointer", "dimension(:)"), - ) - ) return tuple(declarations) - def _logical_array_argument_initializers( - self, - plan: FunctionPlan, - ) -> tuple[FortranAssignment, ...]: - """Copy required one-byte Boolean inputs into exact-kind native arrays.""" - return tuple( - FortranAssignment( - self._logical_array_native_name(argument), - CodeExpression(self._array_boundary_argument_expression(argument)), - ) - for argument in plan.arguments - if argument.array_logical_abi is ArrayLogicalABI.NATIVE_KIND_COPY - and argument.array_copy_in - and argument.entrypoint.optional_mode is OptionalMode.REQUIRED - ) - - def _logical_array_argument_finalizers( - self, - plan: FunctionPlan, - ) -> tuple[FortranAssignment | FortranIf, ...]: - """Copy exact-kind logical outputs into canonical one-byte storage. - - ``merge`` converts truth values while assigning them to the original - ``logical(c_bool)`` view, so copy-out and canonicalization share one - array traversal. Optional buffers are written only when present. - """ - finalizers = [] - for argument in plan.arguments: - if argument.array_logical_abi is not ArrayLogicalABI.NATIVE_KIND_COPY or not argument.array_copy_out: - continue - target = self._array_boundary_argument_expression(argument) - native = self._logical_array_native_name(argument) - assignment = FortranAssignment( - target, - CodeExpression(f"merge(.true._c_bool, .false._c_bool, {native})"), - ) - if argument.entrypoint.optional_mode is OptionalMode.REQUIRED: - finalizers.append(assignment) - else: - finalizers.append(FortranIf(CodeExpression(self._presence_condition(argument)), body=(assignment,))) - return tuple(finalizers) - - @staticmethod - def _logical_array_native_name(argument: ArgumentTransferPlan) -> str: - """Return the bridge-local exact-kind array name for ``argument``.""" - return f"{argument.entrypoint.parameter_name}_native" - - def _logical_array_dimension_attribute(self, argument: ArgumentTransferPlan) -> str: - """Render automatic-array extents in the completed native orientation.""" - array = argument.array - if array is None or array.rank is None: - raise ValueError(f"Logical array {argument.owner_path!r} requires a concrete rank") - name = argument.entrypoint.parameter_name - extents = [f"{name}_extent_{axis}" for axis in range(array.rank)] - if array.native_order == "ORDER_C": - extents.reverse() - return f"dimension({', '.join(extents)})" - - def _array_writeback_finalizers( - self, - plan: FunctionPlan, - ) -> tuple[FortranAssignment | FortranCall | FortranIf | FortranSelectCase, ...]: - """Normalize mutable array bytes through their completed writeback ABI.""" - finalizers = [] - for argument in plan.arguments: - match argument.array_writeback_abi: - case ArrayWritebackABI.NOT_APPLICABLE | ArrayWritebackABI.NATIVE_ARRAY: - continue - case ArrayWritebackABI.LOGICAL_LOW_BIT_INT8: - nodes = self._logical_array_writeback_nodes(argument) - case _: - raise ValueError( - f"Unsupported array writeback ABI for {argument.owner_path!r}: {argument.array_writeback_abi!r}" - ) - if argument.entrypoint.optional_mode is OptionalMode.REQUIRED: - finalizers.extend(nodes) - else: - finalizers.append(FortranIf(CodeExpression(self._presence_condition(argument)), body=nodes)) - return tuple(finalizers) - - def _logical_array_writeback_nodes( - self, - argument: ArgumentTransferPlan, - ) -> tuple[FortranAssignment | FortranCall | FortranSelectCase, ...]: - """Associate raw Boolean storage and retain only each element's truth bit.""" - array = argument.array - if array is None: - raise ValueError(f"Logical array {argument.owner_path!r} has no handoff") - if array.rank is not None: - return self._logical_array_writeback_for_rank(argument, array.rank) - name = argument.entrypoint.parameter_name - cases = tuple( - FortranCase( - rank, - self._logical_array_writeback_for_rank(argument, rank), - ) - for rank in range(1, 16) - ) - return (FortranSelectCase(CodeExpression(f"{name}_rank"), (*cases, FortranCase(None, ()))),) - - def _logical_array_writeback_for_rank( - self, - argument: ArgumentTransferPlan, - rank: int, - ) -> tuple[FortranCall | FortranAssignment, ...]: - """Return logical-array writeback nodes for one rank using the completed ABI conversion action.""" - name = argument.entrypoint.parameter_name - byte_pointer = self._logical_array_byte_pointer_name(argument) - byte_count = " * ".join(f"{name}_extent_{axis}" for axis in range(rank)) - return ( - FortranCall( - "c_f_pointer", - ( - CodeExpression(f"bound_{name}"), - CodeExpression(byte_pointer), - CodeExpression(f"[{byte_count}]"), - ), - ), - FortranAssignment( - byte_pointer, - CodeExpression(self._logical_array_canonical_expression(argument.semantic_type_name, byte_pointer)), - ), - ) - - @staticmethod - def _logical_array_integer_type(semantic_type_name: str) -> str: - """Return the integer type covering one Boolean element's own width. - - The mask reinterprets the caller's buffer, so it has to step by the - element width rather than by bytes: a `logical(4)` array is four-byte - integers, not four times as many one-byte ones. - """ - return { - "Bool": "integer(c_int8_t)", - "Bool8": "integer(c_int8_t)", - "Bool16": "integer(c_int16_t)", - "Bool32": "integer(c_int32_t)", - "Bool64": "integer(c_int64_t)", - }[semantic_type_name] - - @staticmethod - def _logical_array_kind_suffix(semantic_type_name: str) -> str: - """Return the integer kind suffix matching one Boolean element's width.""" - return { - "Bool": "c_int8_t", - "Bool8": "c_int8_t", - "Bool16": "c_int16_t", - "Bool32": "c_int32_t", - "Bool64": "c_int64_t", - }[semantic_type_name] - - def _logical_array_canonical_expression(self, semantic_type_name: str, target: str) -> str: - """Return the expression reducing Boolean storage to zero and one. - - The rule is C's: any non-zero value is true, which is what converting to - ``_Bool`` produces and what NumPy, Python and C all read back. It is not - a low-bit test -- that would call ``2`` false, disagreeing with every one - of them -- and it maps both representations compilers emit, ``1`` and - ``-1``, onto the single value the interoperable type is defined to hold. - """ - kind = self._logical_array_kind_suffix(semantic_type_name) - return f"merge(1_{kind}, 0_{kind}, {target} /= 0_{kind})" - - @staticmethod - def _logical_array_byte_pointer_name(argument: ArgumentTransferPlan) -> str: - """Return the bridge-local byte-pointer name for one logical-array rank conversion.""" - return f"{argument.entrypoint.parameter_name}_logical_bytes" - def _array_initializers(self, plan: FunctionPlan) -> tuple[FortranCall | FortranIf, ...]: """Associate each completed ordinary array data/extent handoff.""" initializers = [] @@ -5383,12 +5182,6 @@ def _array_pointer_name(self, argument: ArgumentTransferPlan) -> str: name = argument.entrypoint.parameter_name return f"{name}_base" if argument.array is not None and argument.array.contiguous is False else name - def _array_native_argument_expression(self, argument: ArgumentTransferPlan) -> str: - """Pass exact-kind logical storage or the planned boundary array view.""" - if argument.array_logical_abi is ArrayLogicalABI.NATIVE_KIND_COPY: - return self._logical_array_native_name(argument) - return self._array_boundary_argument_expression(argument) - def _array_boundary_argument_expression(self, argument: ArgumentTransferPlan) -> str: """Return the array the native call receives: a buffer or a section of one.""" array = argument.array diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index cf1794438..416b978c0 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -38,9 +38,7 @@ from prik.policy.models import ( ArgumentHandoffMode, ArrayEntrypointABI, - ArrayLogicalABI, ArrayPythonLayout, - ArrayWritebackABI, BridgeDataAction, CallbackABIKind, CallbackFatalAction, @@ -2015,7 +2013,6 @@ def _argument_diagnostics( *self._optional_argument_diagnostics(plan), *self._argument_family_diagnostics(plan, available_roles), *self._argument_transformation_diagnostics(plan), - *self._array_writeback_abi_diagnostics(plan), *self._argument_data_action_diagnostics(plan), *self._bridge_data_diagnostics( plan.owner_path, @@ -2025,28 +2022,6 @@ def _argument_diagnostics( ] return tuple(diagnostics) - def _array_writeback_abi_diagnostics( - self, - plan: ArgumentTransferPlan, - ) -> tuple[WrapperPlanDiagnostic, ...]: - """Validate completed mutable-array normalization without selecting it.""" - expected = ArrayWritebackABI.NOT_APPLICABLE - if plan.entrypoint.handoff_mode is ArgumentHandoffMode.ARRAY_BUFFER and ( - plan.mutates_native or self._publishes_array_replacement(plan) - ): - # Every element type is written back the same way: a Boolean one - # already holds the zero or one its interoperable form requires. - expected = ArrayWritebackABI.NATIVE_ARRAY - if plan.array_writeback_abi is expected: - return () - return ( - self._diagnostic( - plan.owner_path, - "invalid-array-writeback-abi", - f"{plan.array_writeback_abi.value}; expected {expected.value}", - ), - ) - # Layer-owned representation transformation validation. def _argument_transformation_diagnostics( self, @@ -2827,16 +2802,6 @@ def _logical_argument_slot_diagnostics( slot.array_native_type, ) ) - if slot.array_copy_in != plan.array_copy_in: - diagnostics.append(self._diagnostic(plan.owner_path, "inconsistent-array-copy-in", slot.array_copy_in)) - if slot.array_copy_out != plan.array_copy_out: - diagnostics.append( - self._diagnostic( - plan.owner_path, - "inconsistent-array-copy-out", - slot.array_copy_out, - ) - ) return tuple(diagnostics) def _argument_slot_consistency_diagnostics( @@ -2879,8 +2844,6 @@ def _expected_argument_data_action(self, plan: ArgumentTransferPlan) -> BridgeDa """Return the data action implied by completed orthogonal selectors.""" if plan.callback is not None: return BridgeDataAction.DIRECT_TRANSFER - if plan.array_logical_abi is ArrayLogicalABI.NATIVE_KIND_COPY: - return BridgeDataAction.COPY_REPRESENTATION if plan.scalar_logical_abi is ScalarLogicalABI.NATIVE_KIND_COPY: return BridgeDataAction.COPY_REPRESENTATION if self._uses_typed_derived_value(plan): @@ -3601,12 +3564,7 @@ def _array_buffer_action_diagnostics( diagnostics.append( self._diagnostic(plan.owner_path, "invalid-array-handoff-mode", plan.entrypoint.handoff_mode.value) ) - expected_data_action = ( - BridgeDataAction.COPY_REPRESENTATION - if plan.array_logical_abi is ArrayLogicalABI.NATIVE_KIND_COPY - else BridgeDataAction.ASSOCIATE_VIEW - ) - if plan.bridge.data_action is not expected_data_action: + if plan.bridge.data_action is not BridgeDataAction.ASSOCIATE_VIEW: diagnostics.append( self._diagnostic(plan.owner_path, "invalid-array-data-action", plan.bridge.data_action.value) ) diff --git a/prik/planning/models.py b/prik/planning/models.py index 788ba3892..d89e7026d 100644 --- a/prik/planning/models.py +++ b/prik/planning/models.py @@ -36,7 +36,6 @@ ArrayLogicalABI, ArrayEntrypointABI, ArrayPythonLayout, - ArrayWritebackABI, BridgeDataAction, CallbackABIKind, CallbackFatalAction, @@ -1092,8 +1091,6 @@ class NativeEntrypointProjectedSlotPlan(StageRecord): scalar_native_type: str | None = None array_logical_abi: ArrayLogicalABI = ArrayLogicalABI.NOT_APPLICABLE array_native_type: str | None = None - array_copy_in: bool = False - array_copy_out: bool = False literal_type: str | None = None literal_value: Any = None result_position: int | None = None @@ -1284,9 +1281,6 @@ class ArgumentTransferPlan(StageRecord): scalar_native_type: str | None array_logical_abi: ArrayLogicalABI array_native_type: str | None - array_copy_in: bool - array_copy_out: bool - array_writeback_abi: ArrayWritebackABI object_kind: ObjectKind ownership_owner: OwnershipOwner transfer_mode: TransferMode diff --git a/prik/planning/planner.py b/prik/planning/planner.py index 69895a38c..65a94da30 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -1778,8 +1778,6 @@ def _projected_slot_plans( scalar_native_type=slot_policy.scalar_native_type, array_logical_abi=slot_policy.array_logical_abi, array_native_type=slot_policy.array_native_type, - array_copy_in=slot_policy.array_copy_in, - array_copy_out=slot_policy.array_copy_out, literal_type=slot_policy.literal_type, literal_value=slot_policy.literal_value, result_position=slot_policy.result_position, @@ -1871,9 +1869,6 @@ def _visit_ArgumentPolicy( scalar_native_type=policy.scalar_native_type, array_logical_abi=policy.array_logical_abi, array_native_type=policy.array_native_type, - array_copy_in=policy.array_copy_in, - array_copy_out=policy.array_copy_out, - array_writeback_abi=policy.array_writeback_abi, object_kind=policy.ownership.kind, ownership_owner=policy.ownership.owner, transfer_mode=policy.ownership.transfer, diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 47030e909..79787efec 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -66,7 +66,6 @@ RAW_STRING_ADDRESS_COPY_REASON, DERIVED_VALUE_COPY_REASON, LOGICAL_SCALAR_KIND_COPY_REASON, - LOGICAL_ARRAY_KIND_COPY_REASON, NativeEntrypointAction, DirectCABITypePolicy, DirectCABIPolicy, @@ -78,7 +77,6 @@ ArgumentConversionPhase, BridgeDataAction, DirectResultABI, - ArrayWritebackABI, ScalarLogicalABI, ArrayLogicalABI, ArrayPythonLayout, @@ -2477,7 +2475,7 @@ def _argument_declares_nullable_c_pointer(argument: ArgumentPolicy, semantic_typ def _argument_requests_native_write(argument: ArgumentPolicy) -> bool: """Return whether a completed contract expects native writes to be visible.""" - return bool(argument.writable or argument.projects_result or argument.array_copy_out) + return bool(argument.writable or argument.projects_result) def _c_direct_scalar_name(semantic_type: models.SemanticType | None) -> str | None: @@ -3065,10 +3063,7 @@ def _argument_policy( function = context.function argument_path = f"{context.owner_path}.{argument.name}" scalar_logical_abi, scalar_native_type = _scalar_logical_argument_abi(argument) - array_logical_abi, array_native_type, array_copy_in, array_copy_out = _array_logical_argument_abi( - argument, - decision, - ) + array_logical_abi, array_native_type = _array_logical_argument_abi(argument) optional_mode = _optional_mode(argument, decision) callback = _callback_handoff_policy(argument) array_policy = _array_handoff_policy( @@ -3147,15 +3142,6 @@ def _argument_policy( scalar_native_type=scalar_native_type, array_logical_abi=array_logical_abi, array_native_type=array_native_type, - array_copy_in=array_copy_in, - array_copy_out=array_copy_out, - array_writeback_abi=_array_writeback_abi( - argument.semantic_type, - decision, - boundary.handoff_mode, - array_policy, - array_logical_abi, - ), optional=argument.optional, optional_mode=boundary.optional_mode, conversion_phase=boundary.conversion_phase, @@ -3272,7 +3258,7 @@ def _completed_argument_bridge_action( native_slot.value_kind if native_slot is not None else None, ) action, reason = _derived_argument_bridge_data_action(derived, action, reason) - return _logical_argument_bridge_action(argument, decision, action, reason) + return _logical_argument_bridge_action(argument, action, reason) def _argument_boundary_policy( @@ -3663,7 +3649,6 @@ def _hidden_result_candidate( ) bridge_data_action, bridge_copy_reason = _logical_argument_bridge_action( argument, - decision, bridge_data_action, bridge_copy_reason, ) @@ -3972,10 +3957,7 @@ def _projected_argument_slot( value_kind = _native_argument_value_kind(argument, mapping.value_kind or "arg") callback = _callback_handoff_policy(argument) scalar_logical_abi, scalar_native_type = _scalar_logical_argument_abi(argument) - array_logical_abi, array_native_type, array_copy_in, array_copy_out = _array_logical_argument_abi( - argument, - decision, - ) + array_logical_abi, array_native_type = _array_logical_argument_abi(argument) derived = _argument_derived_handoff(argument, decision, callback, argument_path, derived_types) bridge_data_action, bridge_copy_reason = _completed_projected_bridge_action( argument, @@ -4009,8 +3991,6 @@ def _projected_argument_slot( scalar_native_type=scalar_native_type, array_logical_abi=array_logical_abi, array_native_type=array_native_type, - array_copy_in=array_copy_in, - array_copy_out=array_copy_out, result_position=mapping.result_position, semantic_type_name=argument.semantic_type.name, character_length=_character_length(argument.semantic_type), @@ -4046,7 +4026,7 @@ def _completed_projected_bridge_action( value_kind, ) action, reason = _derived_argument_bridge_data_action(derived, action, reason) - return _logical_argument_bridge_action(argument, decision, action, reason) + return _logical_argument_bridge_action(argument, action, reason) def _native_slot_barrier_actions( @@ -4138,15 +4118,11 @@ def _hidden_result_native_call_slot_policy( ) bridge_data_action, bridge_copy_reason = _logical_argument_bridge_action( argument, - decision, bridge_data_action, bridge_copy_reason, ) scalar_logical_abi, scalar_native_type = _scalar_logical_argument_abi(argument) - array_logical_abi, array_native_type, array_copy_in, array_copy_out = _array_logical_argument_abi( - argument, - decision, - ) + array_logical_abi, array_native_type = _array_logical_argument_abi(argument) blockers = ( (f"native-call result slot {native_position} has no completed bridge data action",) if bridge_data_action is BridgeDataAction.BLOCKED @@ -4171,8 +4147,6 @@ def _hidden_result_native_call_slot_policy( scalar_native_type=scalar_native_type, array_logical_abi=array_logical_abi, array_native_type=array_native_type, - array_copy_in=array_copy_in, - array_copy_out=array_copy_out, result_position=mapping.result_position, semantic_type_name=argument.semantic_type.name, character_length=_character_length(argument.semantic_type), @@ -4296,10 +4270,7 @@ def _implicit_native_call_slot_policies( continue value_kind = _native_argument_value_kind(argument, "arg") scalar_logical_abi, scalar_native_type = _scalar_logical_argument_abi(argument) - array_logical_abi, array_native_type, array_copy_in, array_copy_out = _array_logical_argument_abi( - argument, - decision, - ) + array_logical_abi, array_native_type = _array_logical_argument_abi(argument) callback = argument.semantic_type.metadata.get(models.RESOLVED_CALLBACK_POLICY_METADATA) callback = callback if isinstance(callback, CallbackHandoffPolicy) else None derived = ( @@ -4329,7 +4300,6 @@ def _implicit_native_call_slot_policies( ) bridge_data_action, bridge_copy_reason = _logical_argument_bridge_action( argument, - decision, bridge_data_action, bridge_copy_reason, ) @@ -4356,8 +4326,6 @@ def _implicit_native_call_slot_policies( scalar_native_type=scalar_native_type, array_logical_abi=array_logical_abi, array_native_type=array_native_type, - array_copy_in=array_copy_in, - array_copy_out=array_copy_out, semantic_type_name=argument.semantic_type.name, character_length=_character_length(argument.semantic_type), array=_array_handoff_policy( @@ -7337,29 +7305,22 @@ def _scalar_logical_argument_abi( def _array_logical_argument_abi( argument: models.SemanticArgument, - decision: OwnershipDecision, -) -> tuple[ArrayLogicalABI, str | None, bool, bool]: - """Complete native storage and directional copies for a Boolean array. +) -> tuple[ArrayLogicalABI, str | None]: + """Complete the native storage one Boolean array is viewed as. - The helper consumes semantic type/origin facts and completed ownership. It - returns the ABI selector, exact native spelling, and independent copy-in - and copy-out flags. Exact ``c_bool`` arrays borrow the NumPy buffer; other - Fortran logical kinds require a bridge-local representation. + The buffer is a NumPy integer of the element's own width, so the native + pointer describes the caller's storage exactly for every logical kind, and + nothing is copied either way. A spelling the source did not record is left + unset; backend lowering then resolves the width from the semantic type. """ semantic_type = argument.semantic_type if not is_boolean_semantic_type_name(semantic_type.name) or int(semantic_type.rank or 0) <= 0: - return ArrayLogicalABI.NOT_APPLICABLE, None, False, False - # The buffer is a NumPy integer of the element's own width, so the native - # pointer describes the caller's storage exactly and no directional copy is - # required for any logical kind. - # A spelling the source did not record is left unset; backend lowering then - # resolves the width from the semantic type itself. - return ArrayLogicalABI.C_BOOL_VIEW, _fortran_logical_native_type(argument), False, False + return ArrayLogicalABI.NOT_APPLICABLE, None + return ArrayLogicalABI.C_BOOL_VIEW, _fortran_logical_native_type(argument) def _logical_argument_bridge_action( argument: models.SemanticArgument, - decision: OwnershipDecision, action: BridgeDataAction, reason: str | None, ) -> tuple[BridgeDataAction, str | None]: @@ -7367,9 +7328,6 @@ def _logical_argument_bridge_action( abi, _native_type = _scalar_logical_argument_abi(argument) if abi is ScalarLogicalABI.NATIVE_KIND_COPY: return BridgeDataAction.COPY_REPRESENTATION, LOGICAL_SCALAR_KIND_COPY_REASON - array_abi, _native_type, _copy_in, _copy_out = _array_logical_argument_abi(argument, decision) - if array_abi is ArrayLogicalABI.NATIVE_KIND_COPY: - return BridgeDataAction.COPY_REPRESENTATION, LOGICAL_ARRAY_KIND_COPY_REASON return action, reason @@ -7515,28 +7473,6 @@ def _argument_handoff_mode(decision: OwnershipDecision) -> ArgumentHandoffMode: # Ordinary-array handoff policy. -def _array_writeback_abi( - semantic_type: models.SemanticType, - decision: OwnershipDecision, - handoff_mode: ArgumentHandoffMode, - array: ArrayHandoffPolicy | None, - logical_abi: ArrayLogicalABI, -) -> ArrayWritebackABI: - """Complete mutable ordinary-array byte normalization before planning. - - A Boolean array needs no more than any other kind. Its elements already - hold the zero or one a C ``_Bool`` is defined to hold, because the compiler - profiles request the option that guarantees it, so there is nothing left to - reduce. Reducing anyway could not help a translation unit built without - that option either: such a compiler represents false as the complement of - true, which no test applied here could tell from a true value. - """ - del logical_abi - if array is None or handoff_mode is not ArgumentHandoffMode.ARRAY_BUFFER or not decision.mutates_native: - return ArrayWritebackABI.NOT_APPLICABLE - return ArrayWritebackABI.NATIVE_ARRAY - - def _array_handoff_policy( semantic_type: models.SemanticType, *, diff --git a/prik/policy/models.py b/prik/policy/models.py index f712cbeb9..4e0b938df 100644 --- a/prik/policy/models.py +++ b/prik/policy/models.py @@ -44,7 +44,6 @@ ) DERIVED_VALUE_COPY_REASON = "pass an exact derived pointee through a typed native value dummy" LOGICAL_SCALAR_KIND_COPY_REASON = "adapt a C-interoperable Boolean through storage with the native Fortran logical kind" -LOGICAL_ARRAY_KIND_COPY_REASON = "adapt a one-byte Boolean array through storage with the native Fortran logical kind" class OptionalMode(str, Enum): @@ -207,14 +206,6 @@ class DirectResultABI(str, Enum): LOGICAL_LOW_BIT_INT8 = "logical_low_bit_int8" -class ArrayWritebackABI(str, Enum): - """Completed post-call element ABI for one mutable ordinary array.""" - - NOT_APPLICABLE = "not_applicable" - NATIVE_ARRAY = "native_array" - LOGICAL_LOW_BIT_INT8 = "logical_low_bit_int8" - - class ScalarLogicalABI(str, Enum): """Completed scalar logical adaptation between the C and native dummies.""" @@ -228,7 +219,6 @@ class ArrayLogicalABI(str, Enum): NOT_APPLICABLE = "not_applicable" C_BOOL_VIEW = "c_bool_view" - NATIVE_KIND_COPY = "native_kind_copy" class WritebackPhase(str, Enum): @@ -1297,9 +1287,6 @@ class ArgumentPolicy: scalar_native_type: str | None array_logical_abi: ArrayLogicalABI array_native_type: str | None - array_copy_in: bool - array_copy_out: bool - array_writeback_abi: ArrayWritebackABI optional: bool optional_mode: OptionalMode conversion_phase: ArgumentConversionPhase @@ -1421,8 +1408,6 @@ class NativeCallSlotPolicy: scalar_native_type: str | None = None array_logical_abi: ArrayLogicalABI = ArrayLogicalABI.NOT_APPLICABLE array_native_type: str | None = None - array_copy_in: bool = False - array_copy_out: bool = False literal_type: str | None = None literal_value: Any = None result_position: int | None = None diff --git a/tests/fortran/arrays/codegen/test_array_output_identity.py b/tests/fortran/arrays/codegen/test_array_output_identity.py index 46229a113..5d149e181 100644 --- a/tests/fortran/arrays/codegen/test_array_output_identity.py +++ b/tests/fortran/arrays/codegen/test_array_output_identity.py @@ -2,12 +2,10 @@ from __future__ import annotations -import pytest from tests.fortran._support.ownership_policy import parse_pyi_text from prik.policy.ownership import CodegenAction, ObjectKind, OwnershipOwner, TransferMode from prik.policy.completion import complete_semantic_policies -from prik.policy.models import ArrayWritebackABI from prik.pipeline.wrapper import WrapperGenerator from prik.planning import WrapperPlanner from prik.planning.models import WritebackPhase @@ -93,13 +91,7 @@ def test_mutable_bool_array_writeback_needs_no_normalization(): because the compiler profiles request the option that guarantees it, so the callee leaves nothing behind that has to be reduced afterwards. """ - plan = _logical_output_plan() - values, out = plan.namespaces[0].functions[0].arguments[1:] - - assert values.array_writeback_abi is ArrayWritebackABI.NATIVE_ARRAY - assert out.array_writeback_abi is ArrayWritebackABI.NATIVE_ARRAY - - artifacts = WrapperGenerator().generate(plan) + artifacts = WrapperGenerator().generate(_logical_output_plan()) bridge_source = next(source.text for source in artifacts.sources if source.path.suffix == ".f90") assert "call native_invert_flags(n, values, out)" in bridge_source @@ -118,12 +110,3 @@ def test_high_rank_bool_array_bridge_stays_inside_the_fortran_line_limit(): assert "dimension(:, :, :, :, :, :, :, :, :, :, :, :, :, :, :), contiguous :: values" in bridge_source assert max(map(len, bridge_source.splitlines())) <= 132 - - -def test_generator_rejects_a_normalized_mutable_bool_array_writeback_abi(): - """An edited plan cannot reintroduce a normalization pass that is not needed.""" - plan = _logical_output_plan() - plan.namespaces[0].functions[0].arguments[-1].array_writeback_abi = ArrayWritebackABI.LOGICAL_LOW_BIT_INT8 - - with pytest.raises(ValueError, match="invalid-array-writeback-abi"): - WrapperGenerator().generate(plan) From 677e2f61f9363d73d53884c2738848de7eaa2fc7 Mon Sep 17 00:00:00 2001 From: said Date: Sat, 19 Sep 2026 17:23:21 +0100 Subject: [PATCH 88/96] Reach each type in the namespace defining it, by its backend symbol A type's class, wrapper helper, and operation maps are defined in the one namespace that defines the type, but generated code looked them up in the namespace of the calling procedure. A procedure of another module returning, accepting, or calling back with the type raised AttributeError, and a generic or polymorphic argument of it never matched. The binding now retains the module object of each namespace defining a type and fetches the type's artifacts there; derived module variables, which already worked this way through per-variable owners, use the same retained namespace object. The callback context no longer carries a module. Those artifacts, and the class constructor entrypoint, were also named after the type's native spelling, so two modules declaring a type spelled alike could not be built together. They are keyed on the backend symbol, which is already qualified where native names collide. Docstrings find a published class name by type identity for the same reason. A plan defining one type in two namespaces is rejected before emission. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 18 ++ docs/developer/packages/codegen.md | 13 +- docs/developer/packages/planning.md | 7 + prik/codegen/c/binding.py | 229 ++++++++---------- prik/codegen/c/naming.py | 29 +-- prik/codegen/c/python_surface.py | 25 +- prik/codegen/docstrings.py | 17 +- prik/pipeline/wrapper.py | 10 + prik/planning/entrypoints.py | 2 +- prik/planning/models.py | 25 +- prik/planning/planner.py | 47 ++-- .../codegen/test_class_surfaces.py | 18 +- .../end_to_end/test_types_across_modules.py | 222 +++++++++++++++++ .../end_to_end/test_multi_source_builds.py | 3 + 14 files changed, 458 insertions(+), 207 deletions(-) create mode 100644 tests/fortran/derived_types/end_to_end/test_types_across_modules.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fbdad070..7c11d1d4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ release tags add a leading `v` to the package version. ## Unreleased +- A type is usable from every module whose procedures take or return it. + Generated code looked a type's class and wrapper helper up in the namespace + of the calling procedure, so `box_ops.boxed()` returning a `shared_types` + type raised `AttributeError`, and a callback result, a polymorphic or + generic argument, or a component of another module's type failed the same + way. The binding now retains the module object of each namespace defining a + type and fetches its class, wrapper helper, and operation maps there; a + derived module variable, which already worked this way, uses the same + mechanism. A plan that defines one type in two namespaces is rejected + (`duplicate-derived-type-identity`). + +- Two modules may each declare a type spelled alike. A type's constructor, + wrapper helper, and operation map were named after its native spelling, so + such a build failed with `Generated support procedure entrypoint symbols + are not unique`; they are now named after its backend symbol, which is + already qualified where native names collide. Docstrings find a type's + published name by its identity for the same reason. + - The retired Boolean-array copy is removed. Policy stopped selecting a native-kind copy or a post-call low-bit normalization for logical arrays once a NumPy integer of the element's own width became their buffer, but diff --git a/docs/developer/packages/codegen.md b/docs/developer/packages/codegen.md index 716500fd0..c96747acc 100644 --- a/docs/developer/packages/codegen.md +++ b/docs/developer/packages/codegen.md @@ -212,7 +212,7 @@ python3 prik/codegen/c/python_surface.py Rendered Python facade: _prik_unset = object() -_prik_ops_state = {} +_prik_ops_state_t = {} class State: 'Opaque native state.' __slots__ = ('_prik_capsule', '_prik_owner', '_prik_ops', '_prik_origin') @@ -225,7 +225,16 @@ def _prik_wrap_state_t(capsule, owner=None, ops=None, origin='direct'): The slots, rejected constructor, and wrapper helper are generated from that class plan. They show the planned Python surface without selecting its native -lifecycle policy. +lifecycle policy. The operation map and wrapper helper are keyed on the type's +backend symbol, which stays unique when two modules declare a type spelled +alike. + +A type is defined in one namespace, and generated code taking or returning it +may live in any other. The binding therefore retains the module object of each +namespace that defines a type, and fetches the class, its wrapper helper, and +its operation maps from there rather than from the calling namespace. A +derived module variable's helpers live beside its type, so its getter reaches +them the same way. ## Tests And Evidence diff --git a/docs/developer/packages/planning.md b/docs/developer/packages/planning.md index 85fa0b4f1..6b9a148eb 100644 --- a/docs/developer/packages/planning.md +++ b/docs/developer/packages/planning.md @@ -159,6 +159,13 @@ the catalogue to join each public class to its completed derived-type, surface, method, and overload policies. The catalogue is read-only: it maps existing owner paths to their semantic declarations without deciding policy again. +Each type is defined in one namespace: the one publishing it, beside its +parent class when it is nested and unpublished, and the root otherwise. +Generated code taking or returning the type reaches its class and helpers +there, so `WrapperGenerator` rejects a plan defining one type twice +(`duplicate-derived-type-identity`). A derived module variable's private +helpers are placed in that same namespace. + The planner attaches class and overload callables to the function collections that need their native entrypoints. It completes generated symbols, adds every required parent namespace, and creates namespace plans in root-first path diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 7db6a5f2e..520d221d3 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -335,13 +335,13 @@ def binding_module(self, plan: ModulePlan) -> CModule: self._binding_owned_derived_owner_paths = frozenset(plan.binding.owned_derived_type_owner_paths) self._binding_allocatable_holder_owner_paths = frozenset(plan.binding.allocatable_holder_type_owner_paths) self._binding_pointer_holder_owner_paths = frozenset(plan.binding.pointer_holder_type_owner_paths) - # Stage 2: complete the immutable name index consumed by Python-surface emission. - class_python_names = {derived.type_identity: derived.definition_name for derived in self._derived_types(plan)} - # Generated code fetching a wrapped type out of its namespace needs the - # name that namespace published it under. That is planned once, here, - # so no emission site re-derives it from the native type name. - self._class_python_names_by_type = { - identity[1].casefold(): name for identity, name in class_python_names.items() + # Stage 2: index the one namespace defining each type. Code taking or + # returning a type can be in any namespace, so it reaches the type's + # class and helpers there, never in its own. + self._type_homes = { + derived.type_identity: (namespace.python_path, derived) + for namespace in plan.namespaces + for derived in namespace.derived_types } # Stage 3: select support and assemble generated functions in dependency order. functions = ( @@ -372,7 +372,7 @@ def binding_module(self, plan: ModulePlan) -> CModule: *self._derived_handle_operation_functions(plan), *self._native_array_operation_functions(plan), *functions, - *self._overload_dispatch_functions(plan, class_python_names), + *self._overload_dispatch_functions(plan), self._module_init(plan, needs_native_support), ), ) @@ -859,7 +859,6 @@ def _callback_runtime_declarations(self, plan: ModulePlan) -> tuple: callback.binding.context_type_symbol, ( CParameter("callable", "PyObject *"), - CParameter("module", "PyObject *"), CParameter("thread_id", "unsigned long"), CParameter( "previous", @@ -970,21 +969,26 @@ def _callback_trampoline_function(self, callback: CallbackHandoffPlan) -> CFunct body=tuple(nodes), ) - @staticmethod - def _wrap_helper_attribute(semantic_type_name: object) -> str: - """Return the internal helper attaching native storage for one type. + def _type_namespace(self, type_identity: tuple[str, str]) -> str: + """Return the retained module object of the namespace a type lives in.""" + return self._namespace_owner_name(self._type_homes[type_identity][0]) - The helper is keyed on the native type's own name, the way - ``CBindingNames.class_wrap_helper`` defines it, so the attribute does - not move when naming policy publishes the type under a different name - and a contract naming its classes in Python still resolves it. - """ - return f"_prik_wrap_{str(semantic_type_name).casefold()}" + def _type_class_name(self, type_identity: tuple[str, str]) -> str: + """Return the name a type's class is defined under in its home.""" + return self._type_homes[type_identity][1].definition_name - def _published_class_name(self, semantic_type_name: str) -> str: - """Return the name the namespace published one wrapped type under.""" - index = getattr(self, "_class_python_names_by_type", {}) - return index.get(str(semantic_type_name).casefold(), str(semantic_type_name)) + def _type_attribute(self, type_identity: tuple[str, str], attribute: str) -> str: + """Return a new reference to one attribute of a type's home namespace.""" + return f'PyObject_GetAttrString({self._type_namespace(type_identity)}, "{attribute}")' + + def _type_class(self, type_identity: tuple[str, str]) -> str: + """Return a new reference to a type's class.""" + return self._type_attribute(type_identity, self._type_class_name(type_identity)) + + def _type_wrap_helper(self, type_identity: tuple[str, str]) -> str: + """Return a new reference to the helper wrapping a type's native storage.""" + backend_symbol = self._type_homes[type_identity][1].backend_symbol + return self._type_attribute(type_identity, CBindingNames.class_wrap_helper(backend_symbol)) @staticmethod def _callback_abort_if_null( @@ -1168,9 +1172,7 @@ def _callback_derived_nodes( CDeclaration( helper, "PyObject *", - CodeExpression( - f'PyObject_GetAttrString(callback_context->module, "{self._wrap_helper_attribute(transfer.semantic_type_name)}")' - ), + CodeExpression(self._type_wrap_helper(transfer.derived_type_identity)), ), CDeclaration( target, @@ -1326,10 +1328,7 @@ def _callback_derived_result_nodes( CDeclaration( "callback_expected_type", "PyObject *", - CodeExpression( - f"PyObject_GetAttrString({context}->module, " - f'"{self._published_class_name(transfer.semantic_type_name)}")' - ), + CodeExpression(self._type_class(transfer.derived_type_identity)), ), self._callback_abort_if_null( callback, @@ -2174,7 +2173,7 @@ def _module_declarations( *self._derived_private_method_prototypes(plan), *self._overload_dispatch_prototypes(plan), *self._derived_handle_operation_declarations(plan), - *self._derived_module_owner_declarations(plan), + *self._namespace_owner_declarations(plan), *self._module_variable_declarations(plan), *self._native_array_operation_declarations(plan), *self._namespace_declarations(plan), @@ -2284,7 +2283,7 @@ def _class_constructor_prototypes(self, plan: ModulePlan) -> tuple[CFunctionProt self._generated_support_procedure_entrypoint(surface.owner_path, "class:create") ), CFunctionPrototype( - CBindingNames.class_create_method(surface), + CBindingNames.class_create_method(surface.backend_symbol), "PyObject *", (CParameter("self", "PyObject *"), CParameter("args", "PyObject *")), "static", @@ -2315,7 +2314,7 @@ def _class_constructor_function( destroy = self._generated_support_procedure_entrypoint(derived.owner_path, "derived:destroy").symbol_name create = self._generated_support_procedure_entrypoint(surface.owner_path, "class:create").symbol_name return CFunction( - CBindingNames.class_create_method(surface), + CBindingNames.class_create_method(surface.backend_symbol), "PyObject *", parameters=(CParameter("self", "PyObject *"), CParameter("args", "PyObject *")), storage="static", @@ -2350,7 +2349,7 @@ def _class_constructor_function( CDeclaration( helper, "PyObject *", - CodeExpression(f'PyObject_GetAttrString(self, "{CBindingNames.class_wrap_helper(surface)}")'), + CodeExpression(self._type_wrap_helper(surface.type_identity)), ), CIf( CodeExpression(f"{helper} == NULL"), @@ -3495,7 +3494,6 @@ def _direct_nested_field_getter(self, derived: DerivedTypePlan, field: DerivedFi """Return direct nested field getter from the supplied completed binding records; this helper preserves the selected binding behavior.""" if field.derived is None: raise ValueError(f"Nested field {field.owner_path!r} has no derived handoff") - child_type = field.derived.type_name child_symbol = field.derived.backend_symbol body = ( *self._derived_owner_address_nodes(derived), @@ -3519,7 +3517,7 @@ def _direct_nested_field_getter(self, derived: DerivedTypePlan, field: DerivedFi CodeExpression(f'PyCapsule_New(child_address, "{self._derived_capsule_name(child_symbol)}", NULL)'), ), CIf(CodeExpression("child_capsule == NULL"), body=(CReturn(CodeExpression("NULL")),)), - *self._borrowed_derived_wrapper_nodes(child_type, "child_capsule", "owner_obj", None), + *self._borrowed_derived_wrapper_nodes(field.derived.type_identity, "child_capsule", "owner_obj", None), ) return self._derived_private_method(self._derived_field_method_name(derived, field, "get"), body) @@ -3533,7 +3531,7 @@ def _direct_nested_field_setter( return None body = ( *self._derived_owner_and_value_nodes(derived), - *self._exact_derived_type_check_nodes(field.derived.type_name, "value_obj", field.name), + *self._exact_derived_type_check_nodes(field.derived.type_identity, "value_obj", field.name), *self._derived_address_from_object_nodes(field.derived.backend_symbol, "value_obj", "value"), CExpressionStatement( CodeExpression( @@ -3557,7 +3555,7 @@ def _module_nested_member_getter( CDeclaration("owner_obj", "PyObject *"), CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &owner_obj)) return NULL')), *self._borrowed_derived_wrapper_nodes( - field.derived.type_name, + field.derived.type_identity, "Py_None", "owner_obj", self._module_member_ops_name(variable, member.path), @@ -3581,7 +3579,7 @@ def _module_nested_member_setter( CExpressionStatement( CodeExpression('if (!PyArg_ParseTuple(args, "OO", &owner_obj, &value_obj)) return NULL') ), - *self._exact_derived_type_check_nodes(field.derived.type_name, "value_obj", field.name), + *self._exact_derived_type_check_nodes(field.derived.type_identity, "value_obj", field.name), *self._derived_address_from_object_nodes(field.derived.backend_symbol, "value_obj", "value"), CExpressionStatement( CodeExpression(f"{self._module_member_bridge_name(variable, member, 'set')}(value_address)") @@ -3592,7 +3590,7 @@ def _module_nested_member_setter( def _borrowed_derived_wrapper_nodes( self, - type_name: str, + type_identity: tuple[str, str], capsule_name: str, owner_name: str, ops_name: str | None, @@ -3604,7 +3602,7 @@ def _borrowed_derived_wrapper_nodes( CDeclaration( "child_helper", "PyObject *", - CodeExpression(f'PyObject_GetAttrString(self, "{self._wrap_helper_attribute(type_name)}")'), + CodeExpression(self._type_wrap_helper(type_identity)), ), CIf( CodeExpression("child_helper == NULL"), @@ -3711,12 +3709,12 @@ def _derived_address_from_object_nodes(self, type_symbol: str, object_name: str, CIf(CodeExpression(f"{address} == NULL"), body=(CReturn(CodeExpression("NULL")),)), ) - @staticmethod - def _exact_derived_type_check_nodes(type_name: str, object_name: str, label: str) -> tuple: + def _exact_derived_type_check_nodes(self, type_identity: tuple[str, str], object_name: str, label: str) -> tuple: """Require the exact exported opaque class before a concrete field copy.""" expected = f"{label}_expected_type" + type_name = self._type_class_name(type_identity) return ( - CDeclaration(expected, "PyObject *", CodeExpression(f'PyObject_GetAttrString(self, "{type_name}")')), + CDeclaration(expected, "PyObject *", CodeExpression(self._type_class(type_identity))), CIf(CodeExpression(f"{expected} == NULL"), body=(CReturn(CodeExpression("NULL")),)), CIf( CodeExpression(f"Py_TYPE({object_name}) != (PyTypeObject *){expected}"), @@ -6450,7 +6448,7 @@ def _lower_module_getter_derived_object(self, plan: ModuleVariablePlan) -> tuple raise ValueError(f"Derived module object {plan.owner_path!r} has no access plan") if derived.access is ModuleObjectAccessMechanism.VALUE_COPY: return self._lower_module_getter_derived_value_copy(plan) - owner = self._derived_module_owner_name(plan) + owner = self._namespace_owner_name(plan.binding.support_namespace) capsule_expression = ( CodeExpression( f"PyCapsule_New({self._module_bridge_getter_name(plan)}(), " @@ -6478,9 +6476,7 @@ def _lower_module_getter_derived_value_copy(self, plan: ModuleVariablePlan) -> t derived = plan.derived if derived is None: raise ValueError(f"Derived module constant {plan.owner_path!r} has no handoff") - type_name = derived.handoff.type_name type_symbol = derived.handoff.backend_symbol - owner = self._derived_module_owner_name(plan) address = "address" capsule = "capsule" helper = "helper" @@ -6518,7 +6514,7 @@ def _lower_module_getter_derived_value_copy(self, plan: ModuleVariablePlan) -> t CDeclaration( helper, "PyObject *", - CodeExpression(f'PyObject_GetAttrString({owner}, "{self._wrap_helper_attribute(type_name)}")'), + CodeExpression(self._type_wrap_helper(derived.handoff.type_identity)), ), CIf( CodeExpression(f"{helper} == NULL"), @@ -6548,12 +6544,11 @@ def _module_derived_wrapper_nodes( """Call the namespace's internal wrapper helper with explicit owner/ops.""" if plan.derived is None: return () - type_name = plan.derived.handoff.type_name nodes = [ CDeclaration( "helper", "PyObject *", - CodeExpression(f'PyObject_GetAttrString({owner}, "{self._wrap_helper_attribute(type_name)}")'), + CodeExpression(self._type_wrap_helper(plan.derived.handoff.type_identity)), ), CIf( CodeExpression("helper == NULL"), @@ -6912,7 +6907,6 @@ def _callback_context_push_nodes( f"{context.arguments[argument.owner_path].object_name}" ) ), - CExpressionStatement(CodeExpression(f"{self._callback_context_name(argument)}.module = self")), CExpressionStatement( CodeExpression(f"{self._callback_context_name(argument)}.thread_id = PyThread_get_thread_ident()") ), @@ -6926,7 +6920,6 @@ def _callback_context_push_nodes( CExpressionStatement( CodeExpression(f"Py_INCREF({context.arguments[argument.owner_path].object_name})") ), - CExpressionStatement(CodeExpression("Py_INCREF(self)")), CExpressionStatement( CodeExpression( f"{argument.callback.binding.context_current_symbol} = &{self._callback_context_name(argument)}" @@ -6954,7 +6947,6 @@ def _callback_context_pop_nodes( CExpressionStatement( CodeExpression(f"Py_XDECREF({self._callback_context_name(argument)}.last_result)") ), - CExpressionStatement(CodeExpression(f"Py_DECREF({self._callback_context_name(argument)}.module)")), CExpressionStatement(CodeExpression(f"Py_DECREF({self._callback_context_name(argument)}.callable)")), ) ) @@ -7143,16 +7135,17 @@ def _polymorphic_argument_nodes( for variant in dispatch.variants: nodes.extend( ( - CExpressionStatement( - CodeExpression(f'{expected} = PyObject_GetAttrString(self, "{variant.python_name}")') - ), + CExpressionStatement(CodeExpression(f"{expected} = {self._type_class(variant.type_identity)}")), CIf(CodeExpression(f"{expected} == NULL"), body=(CReturn(CodeExpression("NULL")),)), CIf( CodeExpression(f"Py_TYPE({names.object_name}) == (PyTypeObject *){expected}"), body=( CExpressionStatement(CodeExpression(f"{code} = {variant.abi_code}")), CExpressionStatement( - CodeExpression(f"{type_name} = {self._c_string_literal(variant.python_name)}") + CodeExpression( + f"{type_name} = " + f"{self._c_string_literal(self._type_class_name(variant.type_identity))}" + ) ), CExpressionStatement( CodeExpression(f"{type_symbol} = {self._c_string_literal(variant.backend_symbol)}") @@ -7168,7 +7161,7 @@ def _polymorphic_argument_nodes( CExpressionStatement(CodeExpression(f"Py_DECREF({expected})")), ) ) - accepted = ", ".join(variant.python_name for variant in dispatch.variants) + accepted = ", ".join(self._type_class_name(variant.type_identity) for variant in dispatch.variants) nodes.append( CIf( CodeExpression(f"{code} == 0"), @@ -10101,9 +10094,7 @@ def _lower_result_derived( CDeclaration( helper, "PyObject *", - CodeExpression( - f'PyObject_GetAttrString(self, "{self._wrap_helper_attribute(plan.derived.type_name)}")' - ), + CodeExpression(self._type_wrap_helper(plan.derived.type_identity)), ), CIf( CodeExpression(f"{helper} == NULL"), @@ -10137,8 +10128,6 @@ def _lower_holder_result( """Wrap one nullable typed holder without exposing its component address.""" if plan.derived is None: raise ValueError(f"Derived result {plan.owner_path!r} has no handoff plan") - type_name = plan.derived.type_name - type_symbol = plan.derived.backend_symbol storage = plan.derived.storage native_name = self._result_native_name(plan, context) python_name = context.python_results[plan.owner_path] @@ -10154,8 +10143,7 @@ def _lower_holder_result( CReturn(CodeExpression("NULL")), ), else_body=self._holder_wrapper_nodes( - type_name, - type_symbol, + plan.derived, storage, self._derived_target_owner(plan.derived), native_name, @@ -10167,8 +10155,7 @@ def _lower_holder_result( def _holder_wrapper_nodes( self, - type_name: str, - type_symbol: str, + derived: DerivedHandoffPlan, storage: DerivedObjectStorage, owner: str, address: str, @@ -10177,7 +10164,7 @@ def _holder_wrapper_nodes( ) -> tuple[CDeclaration | CExpressionStatement | CIf, ...]: """Construct one holder-backed wrapper with a single cleanup path.""" capsule_name, destructor_name, destroy_name, ops_name, origin = self._holder_wrapper_symbols( - type_symbol, + derived.backend_symbol, storage, ) capsule = f"{target}_capsule" @@ -10200,7 +10187,7 @@ def _holder_wrapper_nodes( CDeclaration( helper, "PyObject *", - CodeExpression(f'PyObject_GetAttrString(self, "{self._wrap_helper_attribute(type_name)}")'), + CodeExpression(self._type_wrap_helper(derived.type_identity)), ), CIf( CodeExpression(f"{helper} == NULL"), @@ -10213,7 +10200,7 @@ def _holder_wrapper_nodes( CDeclaration( ops, "PyObject *", - CodeExpression(f'PyObject_GetAttrString(self, "{ops_name}")'), + CodeExpression(self._type_attribute(derived.type_identity, ops_name)), ), CIf( CodeExpression(f"{ops} == NULL"), @@ -13066,8 +13053,7 @@ def _holder_writeback_value_nodes( CExpressionStatement(CodeExpression(f"{result} = Py_None")), ), else_body=self._holder_wrapper_nodes( - source.derived.type_name, - source.derived.backend_symbol, + source.derived, storage, self._derived_target_owner(source.derived), names.value_name, @@ -14745,22 +14731,27 @@ def _derived_module_variables(self, plan: ModulePlan) -> tuple[ModuleVariablePla """Return every live native-owned derived module object.""" return tuple(variable for variable in self._variables(plan) if variable.derived is not None) - def _derived_module_owner_declarations(self, plan: ModulePlan) -> tuple[CDeclaration, ...]: - """Retain the Python module owner for borrowed derived objects.""" + def _owner_namespaces(self, plan: ModulePlan) -> tuple[tuple[str, ...], ...]: + """Return each namespace generated code reaches outside a call's own. + + That is every type's home, and the namespace each derived module + object's helpers live in, which also owns the objects it lends out. + """ + paths = {path for path, _ in self._type_homes.values()} + paths.update(variable.binding.support_namespace for variable in self._derived_module_variables(plan)) + return tuple(sorted(paths)) + + def _namespace_owner_declarations(self, plan: ModulePlan) -> tuple[CDeclaration, ...]: + """Retain the module object of each namespace reached from another.""" return tuple( - CDeclaration( - self._derived_module_owner_name(variable), - "static PyObject *", - CodeExpression("NULL"), - ) - for variable in self._derived_module_variables(plan) + CDeclaration(self._namespace_owner_name(path), "static PyObject *", CodeExpression("NULL")) + for path in self._owner_namespaces(plan) ) - @staticmethod - def _derived_module_owner_name(variable: ModuleVariablePlan) -> str: - """Return the binding-local derived module owner name derived from the supplied completed binding records; this helper preserves completed policy.""" - owner = re.sub(r"\W", "_", variable.owner_path).casefold() - return f"prik_module_{owner}_derived_owner" + @classmethod + def _namespace_owner_name(cls, python_path: tuple[str, ...]) -> str: + """Return the retained module object of one namespace.""" + return f"prik_namespace_{cls._path_symbol(python_path)}_owner" def _method_table(self, module: ModulePlan, namespace: NamespacePlan) -> CMethodDefTable: """Build method table from the supplied completed binding records; emitted nodes only project completed binding actions.""" @@ -14788,8 +14779,8 @@ def _method_entries( *self._overload_method_entries(namespace), *( CMethodDefEntry( - CBindingNames.class_create_method(surface), - CBindingNames.class_create_method(surface), + CBindingNames.class_create_method(surface.backend_symbol), + CBindingNames.class_create_method(surface.backend_symbol), "METH_VARARGS", "", ) @@ -14844,23 +14835,15 @@ def _namespace_overload_dispatches(namespace: NamespacePlan) -> tuple[_COverload seen.add(id(overload)) return tuple(dispatches) - def _overload_dispatch_functions( - self, - plan: ModulePlan, - class_python_names: dict[tuple[str, str], str], - ) -> tuple[CFunction, ...]: + def _overload_dispatch_functions(self, plan: ModulePlan) -> tuple[CFunction, ...]: """Lower every completed overload surface into one C dispatcher.""" return tuple( - self._overload_dispatch_function(dispatch, class_python_names) + self._overload_dispatch_function(dispatch) for namespace in plan.namespaces for dispatch in self._namespace_overload_dispatches(namespace) ) - def _overload_dispatch_function( - self, - dispatch: _COverloadDispatch, - class_python_names: dict[tuple[str, str], str], - ) -> CFunction: + def _overload_dispatch_function(self, dispatch: _COverloadDispatch) -> CFunction: """Classify one call, assign a candidate ID, and switch to its wrapper.""" overload = dispatch.overload positional_offset = 1 if dispatch.receiver else 0 @@ -14880,7 +14863,6 @@ def _overload_dispatch_function( + self._overload_candidate_condition( matches, positional_offset=positional_offset, - class_python_names=class_python_names, ) + ")" ), @@ -14974,7 +14956,6 @@ def _overload_candidate_condition( matches: tuple[OverloadArgumentMatchPlan, ...], *, positional_offset: int, - class_python_names: dict[tuple[str, str], str], ) -> str: """Return one ordered candidate predicate over borrowed call arguments.""" shape = self._overload_call_shape_condition(matches) @@ -14982,7 +14963,6 @@ def _overload_candidate_condition( self._overload_argument_condition( match, self._overload_argument_value_expression(match, index, positional_offset), - class_python_names, ) for index, match in enumerate(matches) ) @@ -15025,10 +15005,9 @@ def _overload_argument_condition( self, match: OverloadArgumentMatchPlan, value: str, - class_python_names: dict[tuple[str, str], str], ) -> str: """Wrap one exact C predicate with its required or optional presence rule.""" - predicate = self._overload_required_argument_condition(match, value, class_python_names) + predicate = self._overload_required_argument_condition(match, value) if match.optional: return f"({value} == NULL || ({predicate}))" return f"({value} != NULL && ({predicate}))" @@ -15037,14 +15016,14 @@ def _overload_required_argument_condition( self, match: OverloadArgumentMatchPlan, value: str, - class_python_names: dict[tuple[str, str], str], ) -> str: """Return the C-API predicate for one completed overload match kind.""" if match.kind is OverloadMatchKind.DERIVED: if match.derived_type_identity is None: raise ValueError(f"Derived overload argument {match.python_name!r} has no type identity") - class_name = self._c_string_literal(class_python_names[match.derived_type_identity]) - expected = f"PyDict_GetItemString(PyModule_GetDict(self), {class_name})" + class_name = self._c_string_literal(self._type_class_name(match.derived_type_identity)) + namespace = self._type_namespace(match.derived_type_identity) + expected = f"PyDict_GetItemString(PyModule_GetDict({namespace}), {class_name})" return f"{expected} != NULL && (PyObject *)Py_TYPE({value}) == {expected}" if match.kind is OverloadMatchKind.NUMPY_ARRAY: numpy_type = PrimitiveScalarTypeRegistry.type_for(match.semantic_type_name).numpy_type_macro @@ -15496,13 +15475,13 @@ def _namespace_configuration_nodes( else () ) return ( + *self._namespace_owner_nodes(module, namespace, object_name), *property_nodes, *self._namespace_python_initializer_nodes( module, namespace, object_name, ), - *self._derived_module_owner_nodes(module, namespace, object_name), *self._module_constant_nodes(module, namespace, object_name), ) @@ -15576,28 +15555,25 @@ def _module_native_array_owner_nodes( ) return tuple(nodes) - def _derived_module_owner_nodes( + def _namespace_owner_nodes( self, module: ModulePlan, namespace: NamespacePlan, module_object: str, ) -> tuple[CIf, ...]: - """Retain one module reference for each live borrowed derived object.""" - nodes = [] - for variable in self._support_variables(module, namespace): - if variable.derived is None: - continue - owner = self._derived_module_owner_name(variable) - nodes.append( - CIf( - CodeExpression(f"{owner} == NULL"), - body=( - CExpressionStatement(CodeExpression(f"Py_INCREF({module_object})")), - CExpressionStatement(CodeExpression(f"{owner} = {module_object}")), - ), - ) - ) - return tuple(nodes) + """Retain this namespace's module object when another reaches into it.""" + if namespace.python_path not in self._owner_namespaces(module): + return () + owner = self._namespace_owner_name(namespace.python_path) + return ( + CIf( + CodeExpression(f"{owner} == NULL"), + body=( + CExpressionStatement(CodeExpression(f"Py_INCREF({module_object})")), + CExpressionStatement(CodeExpression(f"{owner} = {module_object}")), + ), + ), + ) def _module_initializer_nodes(self, plan: ModulePlan) -> tuple[CExpressionStatement, ...]: """Return import-time native assignments selected by completed policy.""" @@ -15879,7 +15855,12 @@ def _namespace(self, plan: ModulePlan, python_path: tuple[str, ...]) -> Namespac def _namespace_symbol(self, plan: NamespacePlan) -> str: """Return the binding-local namespace symbol derived from the supplied completed binding records; this helper preserves completed policy.""" - return "_".join(plan.python_path).casefold() if plan.python_path else "root" + return self._path_symbol(plan.python_path) + + @staticmethod + def _path_symbol(python_path: tuple[str, ...]) -> str: + """Return the C symbol fragment naming one namespace path.""" + return "_".join(python_path).casefold() if python_path else "root" def _namespace_object_name(self, plan: NamespacePlan) -> str: """Return the binding-local namespace object name derived from the supplied completed binding records; this helper preserves completed policy.""" diff --git a/prik/codegen/c/naming.py b/prik/codegen/c/naming.py index 611de6c00..4e6e5c42e 100644 --- a/prik/codegen/c/naming.py +++ b/prik/codegen/c/naming.py @@ -4,7 +4,6 @@ from prik.naming.native_symbols import NativeSymbolNames from prik.planning.models import ( - ClassSurfacePlan, DerivedFieldPlan, DerivedMemberPathPlan, DerivedTypePlan, @@ -103,29 +102,27 @@ def module_derived_presence_method(variable: ModuleVariablePlan) -> str: return f"_prik_module_{variable.symbol_name.casefold()}_require_present" @staticmethod - def class_create_method(surface: ClassSurfacePlan) -> str: + def type_ops(backend_symbol: str) -> str: + """Return the Python operation-map name for a type's direct storage.""" + return f"_prik_ops_{backend_symbol.casefold()}" + + @staticmethod + def class_create_method(backend_symbol: str) -> str: """Return the private C constructor callable installed in the namespace.""" - return f"_prik_create_{surface.type_identity[1].casefold()}" + return f"_prik_create_{backend_symbol.casefold()}" @staticmethod - def class_wrap_helper( - surface: ClassSurfacePlan | None, - *, - fallback: str | None = None, - ) -> str: + def class_wrap_helper(backend_symbol: str) -> str: """Return the Python helper attaching existing native storage. The helper is internal, and the generated code reaching for it knows the native type it is wrapping rather than the name Python publishes - that type under, so it is keyed on the type's own identity the way - ``class_create_method`` is. Keying it on the published name instead - would move it whenever naming policy spells the class differently and - leave every such lookup resolving to nothing. + that type under, so it is keyed on the type's backend symbol the way + every other per-type helper is. That symbol is unique across the + extension, where the native name alone is not: two modules may each + declare a type spelled alike. """ - name = surface.type_identity[1].casefold() if surface is not None else fallback - if name is None: - raise ValueError("Class wrapper helper requires a native type name") - return f"_prik_wrap_{name}" + return f"_prik_wrap_{backend_symbol.casefold()}" @staticmethod def overload_dispatch_symbol(overload: OverloadPlan) -> str: diff --git a/prik/codegen/c/python_surface.py b/prik/codegen/c/python_surface.py index f7e1476ab..0d966c305 100644 --- a/prik/codegen/c/python_surface.py +++ b/prik/codegen/c/python_surface.py @@ -147,7 +147,7 @@ def _derived_type_python_source( ] lines.extend(self._class_constructor_python_lines(surface)) lines.extend(self._derived_class_member_python_lines(derived, surface)) - lines.extend(self._class_wrap_helper_python_lines(surface, name, ops_name)) + lines.extend(self._class_wrap_helper_python_lines(derived, ops_name)) lines.extend(self._unbound_class_python_lines(derived, class_names)) return "\n".join(lines) @@ -226,16 +226,12 @@ def _class_overload_python_source_lines(self, overloads: tuple[OverloadPlan, ... """Flatten overload descriptors while preserving plan order.""" return tuple(line for overload in overloads for line in self._class_overload_python_lines(overload)) - def _class_wrap_helper_python_lines( - self, - surface: ClassSurfacePlan | None, - name: str, - ops_name: str, - ) -> tuple[str, ...]: + @staticmethod + def _class_wrap_helper_python_lines(derived: DerivedTypePlan, ops_name: str) -> tuple[str, ...]: """Render the sole helper that attaches existing opaque native storage.""" return ( - f"def {CBindingNames.class_wrap_helper(surface, fallback=name)}(capsule, owner=None, ops=None, origin='direct'):", - f" value = object.__new__({name})", + f"def {CBindingNames.class_wrap_helper(derived.backend_symbol)}(capsule, owner=None, ops=None, origin='direct'):", + f" value = object.__new__({derived.definition_name})", " value._prik_capsule = capsule", " value._prik_owner = owner", f" value._prik_ops = {ops_name} if ops is None else ops", @@ -278,7 +274,7 @@ def _default_constructor_python_lines(self, surface: ClassSurfacePlan) -> tuple[ signature = f", *, {parameters}" if parameters else "" lines = [ " def __new__(cls, *args, **kwargs):", - f" return {CBindingNames.class_create_method(surface)}()", + f" return {CBindingNames.class_create_method(surface.backend_symbol)}()", f" def __init__(self{signature}):", f" {surface.constructor.docstring!r}", ] @@ -301,7 +297,7 @@ def _bound_constructor_python_lines(self, surface: ClassSurfacePlan) -> tuple[st parameters = self._callable_public_arguments(target) lines = [ " def __new__(cls, *args, **kwargs):", - f" return {CBindingNames.class_create_method(surface)}()", + f" return {CBindingNames.class_create_method(surface.backend_symbol)}()", f" def __init__(self{self._python_parameter_suffix(parameters)}):", f" {surface.constructor.docstring!r}", " _prik_arguments = {'self': self}", @@ -324,7 +320,7 @@ def _overloaded_constructor_python_lines(self, surface: ClassSurfacePlan) -> tup if overload.candidate_passed_objects and overload.candidate_passed_objects[0]: return ( " def __new__(cls, *args, **kwargs):", - f" return {CBindingNames.class_create_method(surface)}()", + f" return {CBindingNames.class_create_method(surface.backend_symbol)}()", *self._class_overload_python_lines( overload, constructor=True, @@ -565,14 +561,14 @@ def _pointer_holder_ops_python_source(self, derived: DerivedTypePlan) -> str: @staticmethod def _direct_type_ops_name(derived: DerivedTypePlan) -> str: """Return the Python operation-map name for direct storage.""" - return f"_prik_ops_{derived.type_name.casefold()}" + return CBindingNames.type_ops(derived.backend_symbol) def _module_proxy_ops_python_source(self, variable: ModuleVariablePlan) -> str: """Return one operation dictionary per reachable plain-module object path.""" if variable.derived is None: return "" if variable.derived.access is ModuleObjectAccessMechanism.DIRECT_ADDRESS: - direct = f"_prik_ops_{variable.derived.handoff.type_name.casefold()}" + direct = CBindingNames.type_ops(variable.derived.handoff.backend_symbol) native_ops = CBindingNames.derived_origin_capsule_method(variable) return f"{CBindingNames.module_member_ops(variable, ())} = dict({direct}, _native_ops={native_ops}())" grouped: dict[tuple[str, ...], list[DerivedMemberPathPlan]] = {} @@ -623,6 +619,7 @@ def _module_proxy_ops_literal( example_surface = ClassSurfacePlan( owner_path="state.State", type_identity=example_identity, + backend_symbol="state_t", python_names=("State",), base_identities=(), constructor=ConstructorPlan( diff --git a/prik/codegen/docstrings.py b/prik/codegen/docstrings.py index 970c376dd..bb548dc64 100644 --- a/prik/codegen/docstrings.py +++ b/prik/codegen/docstrings.py @@ -95,7 +95,7 @@ def render(self, plan: ModulePlan) -> ModulePlan: # way its namespace publishes it. Planning settled that name; indexing # it here keeps every rendered signature reading the same one. self._published_class_names = { - derived.type_identity[1].casefold(): derived.contract_name + derived.type_identity: derived.contract_name for namespace in plan.namespaces for derived in namespace.derived_types } @@ -937,10 +937,17 @@ def _type(self, transfer, *, nullable: bool, signature: bool) -> str: return type_name return f"{type_name} | None" if signature else f"{type_name} or None" - def _published_class_name(self, semantic_type_name: object) -> str: - """Return the name a namespace publishes one wrapped type under.""" + def _published_class_name(self, transfer) -> str: + """Return the name a namespace publishes one wrapped type under. + + The type is found by its identity: two modules may each declare a type + spelled alike, and each is published under its own name. + """ + derived = getattr(transfer, "derived", None) + handoff = getattr(derived, "handoff", derived) + identity = handoff.type_identity if handoff is not None else transfer.derived_type_identity index = getattr(self, "_published_class_names", {}) - return index.get(str(semantic_type_name).casefold(), str(semantic_type_name)) + return index.get(identity, str(transfer.semantic_type_name)) def _base_type(self, transfer) -> str: """Map one completed transfer family and storage facet to public type text. @@ -952,7 +959,7 @@ def _base_type(self, transfer) -> str: if getattr(transfer, "datatype_family", None) is DatatypeFamily.CALLBACK: return self._callback_type(transfer.callback) if getattr(transfer, "datatype_family", None) is DatatypeFamily.DERIVED: - return self._published_class_name(transfer.semantic_type_name) + return self._published_class_name(transfer) scalar = _SCALAR_TYPES.get(transfer.semantic_type_name, transfer.semantic_type_name) array_element = _ARRAY_ELEMENT_TYPES.get(transfer.semantic_type_name, scalar) handle = getattr(transfer, "native_array_handle", None) diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index 416b978c0..149acb052 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -340,6 +340,7 @@ def _plan_diagnostics(self, plan: ModulePlan) -> tuple[WrapperPlanDiagnostic, .. # Validate graph-wide ordering, generated spellings, and header dependencies. diagnostics.extend(self._class_graph_diagnostics(plan)) + diagnostics.extend(self._derived_type_identity_diagnostics(plan)) diagnostics.extend(self._generated_symbol_diagnostics(plan)) diagnostics.extend(self._required_header_diagnostics(plan)) return tuple(diagnostics) @@ -858,6 +859,15 @@ def _class_graph_diagnostics(self, plan: ModulePlan) -> tuple[WrapperPlanDiagnos seen.add(surface.type_identity) return tuple(diagnostics) + def _derived_type_identity_diagnostics(self, plan: ModulePlan) -> tuple[WrapperPlanDiagnostic, ...]: + """Require each type to be defined once, where generated code reaches it.""" + counts = Counter(derived.type_identity for namespace in plan.namespaces for derived in namespace.derived_types) + return tuple( + self._diagnostic(plan.owner_path, "duplicate-derived-type-identity", identity) + for identity, count in counts.items() + if count > 1 + ) + # Derived-type definition, field, and module validation. def _derived_type_diagnostics(self, plan: NamespacePlan) -> tuple[WrapperPlanDiagnostic, ...]: """Validate namespace-owned opaque type and field identities.""" diff --git a/prik/planning/entrypoints.py b/prik/planning/entrypoints.py index f86889bf7..4b66b114a 100644 --- a/prik/planning/entrypoints.py +++ b/prik/planning/entrypoints.py @@ -420,7 +420,7 @@ def _class_constructor_operations(self) -> tuple[GeneratedSupportProcedureEntryp self._operation( surface.owner_path, "class:create", - f"bind_c_prik_create_{surface.type_identity[1].casefold()}", + f"bind_c_prik_create_{surface.backend_symbol.casefold()}", result=self._opaque_result(), ) for surface in self.classes diff --git a/prik/planning/models.py b/prik/planning/models.py index d89e7026d..1e75d2358 100644 --- a/prik/planning/models.py +++ b/prik/planning/models.py @@ -336,6 +336,10 @@ class DerivedTypePlan(StageRecord): take or return one. ``python_names`` are the names this namespace binds it under, possibly none; ``contract_name`` is what the contract calls it; and ``nested_in`` names the class it is bound on instead of a namespace. + + A type is defined in one namespace only. Code taking or returning it can + live in any namespace, so generated code reaches the class and its helpers + in the namespace defining it rather than in its own. """ owner_path: str @@ -353,19 +357,14 @@ class DerivedTypePlan(StageRecord): @property def definition_name(self) -> str: - """Return the name generated code defines and reaches this type by here.""" - return type_definition_name(self.python_names, self.backend_symbol) - + """Return the name generated code defines and reaches this type by here. -def type_definition_name(python_names: tuple[str, ...], backend_symbol: str) -> str: - """Return the name generated code defines a type under and reaches it by. - - A bound type is defined under the first name it is bound as. A type bound - under no public name is still defined -- generated code has to reach the - class to wrap a returned instance, subclass it, or check an argument -- so - it takes a private name no contract publishes. - """ - return python_names[0] if python_names else f"_prik_type_{backend_symbol}" + A bound type is defined under the first name it is bound as. A type + bound under no public name is still defined -- generated code has to + reach the class to wrap a returned instance, subclass it, or check an + argument -- so it takes a private name no contract publishes. + """ + return self.python_names[0] if self.python_names else f"_prik_type_{self.backend_symbol}" @dataclass @@ -464,6 +463,7 @@ class ClassSurfacePlan(StageRecord): owner_path: str type_identity: tuple[str, str] + backend_symbol: str python_names: tuple[str, ...] base_identities: tuple[tuple[str, str], ...] constructor: ConstructorPlan @@ -1113,7 +1113,6 @@ class PolymorphicVariantPlan(StageRecord): type_identity: tuple[str, str] backend_symbol: str - python_name: str abi_code: int diff --git a/prik/planning/planner.py b/prik/planning/planner.py index 65a94da30..8bdaa2037 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -147,7 +147,6 @@ CharacterLocalPlan, ScalarDescriptorResultPlan, TransformationPlan, - type_definition_name, ) from prik.naming.native_symbols import NativeSymbolNames from prik.semantics.scalar_types import BOOLEAN_SEMANTIC_TYPE_NAMES @@ -567,33 +566,29 @@ def _namespace_plans( ) for path in namespace_paths ) - self._complete_variable_support_namespaces(module_name, variables, namespaces) + self._complete_variable_support_namespaces(variables, namespaces) return namespaces @staticmethod def _complete_variable_support_namespaces( - module_name: str, variables: tuple[ModuleVariablePlan, ...], namespaces: tuple[NamespacePlan, ...], ) -> None: - """Place private variable helpers without changing canonical ownership.""" - type_paths: dict[tuple[str, str], list[tuple[str, ...]]] = defaultdict(list) - for namespace in namespaces: - for derived in namespace.derived_types: - type_paths[derived.type_identity].append(namespace.python_path) + """Place a derived variable's private helpers beside its type's own. + + They wrap the variable with that type's class and extend its operation + map, so they live in the one namespace defining the type. Canonical + ownership does not move. + """ + defined_in = { + derived.type_identity: namespace.python_path + for namespace in namespaces + for derived in namespace.derived_types + } for variable in variables: - if variable.derived is None: - variable.binding.support_namespace = () - continue - identity = variable.derived.handoff.type_identity - candidates = type_paths.get(identity, [()]) - native_scope = identity[0] - native_path = ( - () - if native_scope.casefold() == module_name.casefold() - else tuple(part.casefold() for part in native_scope.split(".") if part) + variable.binding.support_namespace = ( + () if variable.derived is None else defined_in.get(variable.derived.handoff.type_identity, ()) ) - variable.binding.support_namespace = native_path if native_path in candidates else candidates[0] def _aliases_by_namespace(self, module: models.SemanticModule) -> dict[tuple[str, ...], list[NamespaceAliasPlan]]: """Group each published re-export under the namespace that publishes it. @@ -692,12 +687,6 @@ def _complete_derived_backend_symbols( self._derived_backend_symbols = { policy.type_identity: self._derived_backend_symbol_for_policy(policy, counts) for policy in policies } - self._class_definition_names = { - policy.type_identity: type_definition_name( - policy.python_names, self._derived_backend_symbols[policy.type_identity] - ) - for policy in policies - } @staticmethod def _derived_backend_symbol_for_policy(policy: DerivedTypePolicy, counts: Counter) -> str: @@ -742,8 +731,10 @@ def _derived_types_by_namespace( def _type_placements(class_policies: _ClassPolicyCatalog) -> tuple[_TypePlacement, ...]: """Return each namespace a type is defined in, and the names bound there. - A type is defined in every namespace that publishes it, under the names - it is published as. A type that publishes nowhere still exists -- a + A type is defined in the namespace that publishes it, under the names + it is published as. Generated code reaches a type in the one namespace + defining it, so a plan defining it in two is rejected. A type that + publishes nowhere still exists -- a published signature may take or return one -- so it is defined once without a public name: beside its parent class, which binds it, when it is nested, and at the root otherwise. @@ -843,6 +834,7 @@ def _class_surface_plan( return ClassSurfacePlan( owner_path=policy.owner_path, type_identity=policy.type_identity, + backend_symbol=self._derived_backend_symbol(policy.type_identity), python_names=python_names, base_identities=policy.base_identities, constructor=constructor, @@ -2045,7 +2037,6 @@ def _polymorphic_dispatch_plan( PolymorphicVariantPlan( type_identity=identity, backend_symbol=self._derived_backend_symbol(identity), - python_name=self._class_definition_names[identity], abi_code=index, ) for index, identity in enumerate(policy.variants, start=1) diff --git a/tests/fortran/derived_types/codegen/test_class_surfaces.py b/tests/fortran/derived_types/codegen/test_class_surfaces.py index ed3714fec..0abedef72 100644 --- a/tests/fortran/derived_types/codegen/test_class_surfaces.py +++ b/tests/fortran/derived_types/codegen/test_class_surfaces.py @@ -44,10 +44,10 @@ def test_inheritance_and_polymorphism_are_completed_before_planning(): assert circle.base_identities == (base.type_identity,) assert [field.name for field in derived.fields] == ["size", "radius"] - assert tuple(variant.python_name for variant in describe.arguments[0].polymorphic.variants) == ( - "Box", - "Circle", - "Base_Shape", + assert tuple(variant.type_identity for variant in describe.arguments[0].polymorphic.variants) == ( + _surface(plan, "Box").type_identity, + circle.type_identity, + base.type_identity, ) @@ -57,3 +57,13 @@ def test_invalid_class_graph_fails_before_emission(): with pytest.raises(ValueError, match="missing-or-late-class-base"): WrapperGenerator().generate(plan) + + +def test_a_type_defined_in_two_namespaces_fails_before_emission(): + """Generated code reaches a type in the one namespace defining it.""" + plan = _plan(INHERITANCE) + namespace = next(item for item in plan.namespaces if item.derived_types) + namespace.derived_types = (*namespace.derived_types, namespace.derived_types[0]) + + with pytest.raises(ValueError, match="duplicate-derived-type-identity"): + WrapperGenerator().generate(plan) diff --git a/tests/fortran/derived_types/end_to_end/test_types_across_modules.py b/tests/fortran/derived_types/end_to_end/test_types_across_modules.py new file mode 100644 index 000000000..864a1bd44 --- /dev/null +++ b/tests/fortran/derived_types/end_to_end/test_types_across_modules.py @@ -0,0 +1,222 @@ +"""A type is one type wherever a procedure of another module takes or returns it. + +Each module becomes its own namespace, and a type's class and the helpers +wrapping it are defined in the namespace of the module declaring it. A +procedure using the type from another module reaches them there, and two +modules may each declare a type spelled alike without either replacing the +other. +""" + +from pathlib import Path + +import numpy as np +import pytest + +from tests.fortran._support.wrapper_build import _build_sources_and_import + +pytestmark = pytest.mark.fortran_end_to_end + +SHAPES_SOURCE = """\ +module shapes + implicit none + type :: box + integer :: value = 0 + end type box + type, extends(box) :: tagged_box + integer :: tag = 0 + end type tagged_box +end module shapes +""" + +OPS_SOURCE = """\ +module ops + use shapes, only: box, tagged_box + implicit none + private + public :: holder, boxed, total, visit, describe, weigh, maybe_box, producer, consumer + type :: holder + type(box) :: inner + end type holder + abstract interface + function producer() result(out) + import :: box + type(box) :: out + end function producer + subroutine consumer(item) + import :: box + type(box), intent(in) :: item + end subroutine consumer + end interface + interface weigh + module procedure weigh_box, weigh_int + end interface weigh +contains + function boxed(v) result(out) + integer, intent(in) :: v + type(box) :: out + out%value = v + end function boxed + + integer function total(make) + procedure(producer) :: make + type(box) :: item + item = make() + total = item%value + end function total + + subroutine visit(fn) + procedure(consumer) :: fn + type(box) :: item + item%value = 41 + call fn(item) + end subroutine visit + + integer function describe(item) + class(box), intent(in) :: item + select type (item) + type is (tagged_box) + describe = 2 + class default + describe = 1 + end select + end function describe + + integer function weigh_box(item) + type(box), intent(in) :: item + weigh_box = item%value + end function weigh_box + + integer function weigh_int(n) + integer, intent(in) :: n + weigh_int = -n + end function weigh_int + + function maybe_box(v) result(out) + integer, intent(in) :: v + type(box), allocatable :: out + allocate(out) + out%value = v + end function maybe_box +end module ops +""" + +FIRST_SOURCE = """\ +module first_mod + implicit none + type :: box + integer :: value = 1 + end type box +contains + function make_first() result(out) + type(box) :: out + out%value = 10 + end function make_first +end module first_mod +""" + +SECOND_SOURCE = """\ +module second_mod + implicit none + type :: box + real(8) :: weight = 2.0d0 + end type box + abstract interface + function producer() result(out) + import :: box + type(box) :: out + end function producer + end interface +contains + real(8) function weigh(make) result(total) + procedure(producer) :: make + type(box) :: item + item = make() + total = item%weight + end function weigh +end module second_mod +""" + + +@pytest.fixture(scope="module") +def modules(tmp_path_factory: pytest.TempPathFactory): + """Build `shapes` and the `ops` module using its types once.""" + module, _ = _build_sources_and_import( + [("shapes.f90", SHAPES_SOURCE), ("ops.f90", OPS_SOURCE)], + tmp_path_factory.mktemp("across"), + ) + return module.shapes, module.ops + + +def test_a_returned_type_is_the_declaring_module_class(modules): + shapes, ops = modules + + item = ops.boxed(np.int32(3)) + + assert type(item) is shapes.Box + assert item.value == 3 + + +def test_an_allocatable_result_is_the_declaring_module_class(modules): + shapes, ops = modules + + item = ops.maybe_box(np.int32(4)) + + assert type(item) is shapes.Box + assert item.value == 4 + + +def test_a_callback_result_is_checked_against_the_declaring_module_class(modules): + shapes, ops = modules + + assert ops.total(lambda: shapes.Box(value=np.int32(9))) == 9 + + +def test_a_callback_argument_is_the_declaring_module_class(modules): + shapes, ops = modules + seen = [] + + ops.visit(lambda item: seen.append((type(item), int(item.value)))) + + assert seen == [(shapes.Box, 41)] + + +def test_a_polymorphic_argument_accepts_each_declaring_module_class(modules): + shapes, ops = modules + + assert ops.describe(shapes.Box()) == 1 + assert ops.describe(shapes.Tagged_Box()) == 2 + + +def test_a_generic_dispatches_on_the_declaring_module_class(modules): + shapes, ops = modules + + assert ops.weigh(shapes.Box(value=np.int32(5))) == 5 + assert ops.weigh(np.int32(5)) == -5 + + +def test_a_component_of_another_module_type_is_that_module_class(modules): + shapes, ops = modules + holder = ops.Holder() + + assert type(holder.inner) is shapes.Box + holder.inner = shapes.Box(value=np.int32(12)) + assert holder.inner.value == 12 + + +def test_two_modules_may_each_declare_a_type_spelled_alike(tmp_path: Path): + """Each `box` keeps its own class, constructor, and helpers. + + Keying them by the native spelling alone gave both types one constructor + symbol, and the build stopped there. + """ + module, _ = _build_sources_and_import( + [("first.f90", FIRST_SOURCE), ("second.f90", SECOND_SOURCE)], + tmp_path, + ) + first, second = module.first_mod, module.second_mod + + assert first.Box is not second.Box + made = first.make_first() + assert type(made) is first.Box + assert made.value == 10 + assert second.weigh(lambda: second.Box(weight=np.float64(3.5))) == 3.5 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 42809a47a..58f2868cd 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 @@ -189,6 +189,9 @@ def _assert_combined_runtime(module) -> None: assert module.second_math.double_after_add(np.int32(4)) == np.int32(10) box = module.shared_types.make_box(np.int32(7)) assert module.box_ops.box_value(box) == np.int32(7) + # `box_ops` returns a type `shared_types` defines, so the result is built + # from that namespace's class rather than looked for in its own. + assert type(module.box_ops.boxed(np.int32(8))) is module.shared_types.Box def test_multi_file_modules_build_one_merged_extension(tmp_path: Path): From af51ffa185d96f962a5668811f7d01aa0325c5a0 Mon Sep 17 00:00:00 2001 From: said Date: Sat, 19 Sep 2026 17:53:28 +0100 Subject: [PATCH 89/96] Let a type extend one another module declares A generated class looked its base up among the classes of its own namespace, so a type extending another module's type failed generation with a KeyError. It now names the base, and the base's operation map, through the namespace defining it, which module initialization binds into the setup script's dictionary before it runs. That namespace has to be set up first. Planning orders namespaces so each comes after the ones defining the bases its classes extend, keeping path order otherwise, and module initialization creates every namespace before setting any of them up. Validation already required this order; it held only when path order happened to agree. The derived-types guide also states that an allocatable derived function result must be allocated when the function returns. gfortran reads the result before any code the bridge can generate sees it, so an unallocated one cannot be turned into None. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN6oB7jC7wZXgFmuec4B7Q --- CHANGELOG.md | 11 +++ docs/developer/packages/codegen.md | 5 +- docs/developer/packages/planning.md | 4 +- docs/user/guide/wrapping-derived-types.md | 5 + prik/codegen/c/binding.py | 42 +++++--- prik/codegen/c/naming.py | 10 ++ prik/codegen/c/python_surface.py | 97 ++++++++++--------- prik/planning/planner.py | 36 ++++++- .../codegen/test_class_surfaces.py | 38 ++++++++ .../end_to_end/test_types_across_modules.py | 54 +++++++++++ 10 files changed, 238 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c11d1d4c..d4aca5618 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ release tags add a leading `v` to the package version. ## Unreleased +- A type may extend a type another module declares. The generated class + looked its base up among its own namespace's classes, so such a build failed + with a `KeyError`; it now names the base through the namespace defining it. + Planning orders namespaces so the one defining a base is set up first, and + module initialization creates every namespace before setting any up. + +- The derived-types guide states that an `allocatable` derived function + result must be allocated when the function returns, as Fortran requires of + every non-pointer result. The compiler reads the result before the wrapper + can, so an unallocated one is a native error rather than `None`. + - A type is usable from every module whose procedures take or return it. Generated code looked a type's class and wrapper helper up in the namespace of the calling procedure, so `box_ops.boxed()` returning a `shared_types` diff --git a/docs/developer/packages/codegen.md b/docs/developer/packages/codegen.md index c96747acc..5725d0552 100644 --- a/docs/developer/packages/codegen.md +++ b/docs/developer/packages/codegen.md @@ -234,7 +234,10 @@ may live in any other. The binding therefore retains the module object of each namespace that defines a type, and fetches the class, its wrapper helper, and its operation maps from there rather than from the calling namespace. A derived module variable's helpers live beside its type, so its getter reaches -them the same way. +them the same way. A class extending a type another namespace defines names its +base through that namespace too: module initialization creates every namespace +first, then sets them up in plan order, and binds each namespace a setup script +reaches into its dictionary before the script runs. ## Tests And Evidence diff --git a/docs/developer/packages/planning.md b/docs/developer/packages/planning.md index 6b9a148eb..3036d4af8 100644 --- a/docs/developer/packages/planning.md +++ b/docs/developer/packages/planning.md @@ -169,7 +169,9 @@ helpers are placed in that same namespace. The planner attaches class and overload callables to the function collections that need their native entrypoints. It completes generated symbols, adds every required parent namespace, and creates namespace plans in root-first path -order. Finally it collects headers selected by completed descriptor-handle +order, except that a namespace whose classes extend a type another namespace +defines comes after that namespace. A namespace's classes are created when it +is set up, in plan order, so the base has to exist first. Finally it collects headers selected by completed descriptor-handle plans and returns one editable `ModulePlan`. ### `models.py`: shared plans and three lowering views diff --git a/docs/user/guide/wrapping-derived-types.md b/docs/user/guide/wrapping-derived-types.md index e410e8e8e..c0a0532f0 100644 --- a/docs/user/guide/wrapping-derived-types.md +++ b/docs/user/guide/wrapping-derived-types.md @@ -217,6 +217,11 @@ print(points.Point.__init__.__doc__) - **Fields**: Public scalar numeric/logical/complex fields become Python attributes. - **Nested types**: Appear as generated objects tied to their parent. - **Results**: Derived-type function results create new independent objects. + An `allocatable` result must be allocated when the function returns, as + Fortran requires of every non-pointer function result; the compiler reads it + before the wrapper can, so returning it unallocated is a native error PRIK + cannot turn into `None`. A `pointer` result may be disassociated: the + returned object then raises `ReferenceError` when its value is read. - **Default constructor**: Automatically generated from public, writable primitive scalar fields. - **Constructor fields**: Passed by keyword (`logical`, `integer`, `real`, and diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 520d221d3..a07879129 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -15357,12 +15357,19 @@ def _module_init( CExpressionStatement(CodeExpression("if (mod == NULL) return NULL")), *self._module_initializer_nodes(plan), *self._module_native_array_owner_nodes(plan, "mod"), - *self._namespace_configuration_nodes( - plan, - root_namespace, - "mod", - ), *(node for namespace in child_namespaces for node in self._child_namespace_nodes(plan, namespace)), + # Every namespace exists before any is set up, and they are set + # up in plan order, which puts a namespace defining a base class + # before one whose classes extend it. + *( + node + for namespace in plan.namespaces + for node in self._namespace_configuration_nodes( + plan, + namespace, + self._namespace_object_name(namespace), + ) + ), *( node for namespace in child_namespaces @@ -15417,7 +15424,7 @@ def _child_namespace_nodes( module: ModulePlan, namespace: NamespacePlan, ) -> tuple[CDeclaration | CExpressionStatement, ...]: - """Create, attach, and configure one child Python module.""" + """Create one child Python module and attach it to its parent.""" object_name = self._namespace_object_name(namespace) parent = self._namespace_object_name(self._namespace(module, namespace.python_path[:-1])) definition = f"{module.binding.owner_path}_{self._namespace_symbol(namespace)}_module" @@ -15431,11 +15438,6 @@ def _child_namespace_nodes( f"{{ Py_DECREF({object_name}); Py_DECREF(mod); return NULL; }}" ) ), - *self._namespace_configuration_nodes( - module, - namespace, - object_name, - ), ) def _child_namespace_import_registration_nodes( @@ -15510,14 +15512,28 @@ def _namespace_python_initializer_nodes( nullable_module_proxy_owner_paths=frozenset( variable.owner_path for variable in variables if self._nullable_derived_module_proxy(variable) ), + type_homes=self._type_homes, ) - source = PythonSurfaceEmitter(context).emit(namespace, variables) + emitter = PythonSurfaceEmitter(context) + source = emitter.emit(namespace, variables) literal = self._c_string_literal(source) result_name = f"{self._namespace_symbol(namespace)}_python_setup" dictionary = f"{self._namespace_symbol(namespace)}_python_dict" return ( CDeclaration(dictionary, "PyObject *", CodeExpression(f"PyModule_GetDict({module_object})")), CIf(CodeExpression(f"{dictionary} == NULL"), body=(CReturn(CodeExpression("NULL")),)), + # A class extending a type another namespace defines reaches its base + # through that namespace, which planning set up before this one. + *( + CIf( + CodeExpression( + f'PyDict_SetItemString({dictionary}, "{CBindingNames.namespace_reference(path)}", ' + f"{self._namespace_owner_name(path)}) < 0" + ), + body=(CReturn(CodeExpression("NULL")),), + ) + for path in emitter.referenced_namespaces(namespace) + ), CDeclaration( result_name, "PyObject *", @@ -15860,7 +15876,7 @@ def _namespace_symbol(self, plan: NamespacePlan) -> str: @staticmethod def _path_symbol(python_path: tuple[str, ...]) -> str: """Return the C symbol fragment naming one namespace path.""" - return "_".join(python_path).casefold() if python_path else "root" + return CBindingNames.namespace_symbol(python_path) def _namespace_object_name(self, plan: NamespacePlan) -> str: """Return the binding-local namespace object name derived from the supplied completed binding records; this helper preserves completed policy.""" diff --git a/prik/codegen/c/naming.py b/prik/codegen/c/naming.py index 4e6e5c42e..0c1171aa2 100644 --- a/prik/codegen/c/naming.py +++ b/prik/codegen/c/naming.py @@ -101,6 +101,16 @@ def module_derived_presence_method(variable: ModuleVariablePlan) -> str: """Return the presence guard for a nullable module-derived object.""" return f"_prik_module_{variable.symbol_name.casefold()}_require_present" + @staticmethod + def namespace_symbol(python_path: tuple[str, ...]) -> str: + """Return the symbol fragment naming one namespace path.""" + return "_".join(python_path).casefold() if python_path else "root" + + @classmethod + def namespace_reference(cls, python_path: tuple[str, ...]) -> str: + """Return the name one namespace's Python source reaches another by.""" + return f"_prik_namespace_{cls.namespace_symbol(python_path)}" + @staticmethod def type_ops(backend_symbol: str) -> str: """Return the Python operation-map name for a type's direct storage.""" diff --git a/prik/codegen/c/python_surface.py b/prik/codegen/c/python_surface.py index 0d966c305..86c995e9c 100644 --- a/prik/codegen/c/python_surface.py +++ b/prik/codegen/c/python_surface.py @@ -13,6 +13,7 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from prik.codegen.c.naming import CBindingNames @@ -40,11 +41,17 @@ @dataclass(frozen=True) class PythonSurfaceContext: - """Store namespace facts already selected by planning and C orchestration.""" + """Store namespace facts already selected by planning and C orchestration. + + ``type_homes`` maps each type identity to the namespace defining it and + its plan there, so a class extending a type another namespace defines + reaches that base where it lives. + """ allocatable_holder_identities: frozenset[tuple[str, str]] pointer_holder_identities: frozenset[tuple[str, str]] nullable_module_proxy_owner_paths: frozenset[str] + type_homes: Mapping[tuple[str, str], tuple[tuple[str, ...], DerivedTypePlan]] class PythonSurfaceEmitter(ClassVisitor): @@ -68,17 +75,10 @@ def _visit_NamespacePlan( ) -> str: """Render one planned namespace as executable Python source.""" surfaces = self._class_surfaces(namespace) - class_names = self._class_names(namespace) - ops_names = self._direct_ops_names(namespace) sections = [ "_prik_unset = object()", *( - self._derived_type_python_source( - derived, - surfaces.get(derived.type_identity), - class_names, - ops_names, - ) + self._derived_type_python_source(namespace, derived, surfaces.get(derived.type_identity)) for derived in namespace.derived_types ), ] @@ -91,14 +91,37 @@ def _class_surfaces(namespace: NamespacePlan) -> dict[tuple[str, str], ClassSurf """Index planned class surfaces by completed type identity.""" return {surface.type_identity: surface for surface in namespace.classes} - @staticmethod - def _class_names(namespace: NamespacePlan) -> dict[tuple[str, str], str]: - """Index the names this namespace defines its classes under.""" - return {derived.type_identity: derived.definition_name for derived in namespace.derived_types} + def referenced_namespaces(self, namespace: NamespacePlan) -> tuple[tuple[str, ...], ...]: + """Return each other namespace this one's source reaches a type in. + + The source names each such namespace as ``CBindingNames.namespace_reference``, + which has to be bound before it runs. + """ + paths = { + self._context.type_homes[base][0] for surface in namespace.classes for base in surface.base_identities[:1] + } + return tuple(sorted(paths - {namespace.python_path})) - def _direct_ops_names(self, namespace: NamespacePlan) -> dict[tuple[str, str], str]: - """Index operation dictionaries inherited by generated subclasses.""" - return {derived.type_identity: self._direct_type_ops_name(derived) for derived in namespace.derived_types} + def _type_reference(self, namespace: NamespacePlan, type_identity: tuple[str, str], attribute: str) -> str: + """Return how this namespace's source names one attribute a type defines. + + A type this namespace defines is named directly; one another namespace + defines is reached through that namespace. + """ + path = self._context.type_homes[type_identity][0] + if path == namespace.python_path: + return attribute + return f"{CBindingNames.namespace_reference(path)}.{attribute}" + + def _type_class_reference(self, namespace: NamespacePlan, type_identity: tuple[str, str]) -> str: + """Return how this namespace's source names a type's class.""" + derived = self._context.type_homes[type_identity][1] + return self._type_reference(namespace, type_identity, derived.definition_name) + + def _type_ops_reference(self, namespace: NamespacePlan, type_identity: tuple[str, str]) -> str: + """Return how this namespace's source names a type's operation map.""" + derived = self._context.type_homes[type_identity][1] + return self._type_reference(namespace, type_identity, self._direct_type_ops_name(derived)) def _holder_ops_python_sources(self, namespace: NamespacePlan) -> tuple[str, ...]: """Render allocatable and pointer holder operation maps by completed identity.""" @@ -126,16 +149,16 @@ def _module_proxy_ops_python_sources( def _derived_type_python_source( self, + namespace: NamespacePlan, derived: DerivedTypePlan, surface: ClassSurfacePlan | None, - class_names: dict[tuple[str, str], str], - ops_names: dict[tuple[str, str], str], ) -> str: """Return one opaque wrapper assembled from its completed class surface.""" name = derived.definition_name ops_name = self._direct_type_ops_name(derived) - base = self._class_base_name(surface, class_names) - base_ops = self._class_base_ops_name(surface, ops_names) + base_identity = surface.base_identities[0] if surface is not None and surface.base_identities else None + base = None if base_identity is None else self._type_class_reference(namespace, base_identity) + base_ops = None if base_identity is None else self._type_ops_reference(namespace, base_identity) slots = self._class_slots(base) own_ops = self._direct_type_ops_literal(derived) combined_ops = self._combined_ops_literal(base_ops, own_ops) @@ -148,14 +171,10 @@ def _derived_type_python_source( lines.extend(self._class_constructor_python_lines(surface)) lines.extend(self._derived_class_member_python_lines(derived, surface)) lines.extend(self._class_wrap_helper_python_lines(derived, ops_name)) - lines.extend(self._unbound_class_python_lines(derived, class_names)) + lines.extend(self._unbound_class_python_lines(namespace, derived)) return "\n".join(lines) - @staticmethod - def _unbound_class_python_lines( - derived: DerivedTypePlan, - class_names: dict[tuple[str, str], str], - ) -> tuple[str, ...]: + def _unbound_class_python_lines(self, namespace: NamespacePlan, derived: DerivedTypePlan) -> tuple[str, ...]: """Name a class bound under no public name, and bind it on its parent. Such a class is defined under a private name, so it takes the name its @@ -168,23 +187,13 @@ def _unbound_class_python_lines( contract = derived.contract_name if derived.nested_in is None: return (f"{name}.__name__ = {name}.__qualname__ = {contract!r}",) - parent = class_names[derived.nested_in] + parent = self._type_class_reference(namespace, derived.nested_in) return ( f"{name}.__name__ = {contract!r}", f"{name}.__qualname__ = {parent}.__qualname__ + {'.' + contract!r}", f"{parent}.{contract} = {name}", ) - @staticmethod - def _class_base_ops_name( - surface: ClassSurfacePlan | None, - ops_names: dict[tuple[str, str], str], - ) -> str | None: - """Return the inherited operation-map name, when one is planned.""" - if surface is None or not surface.base_identities: - return None - return ops_names[surface.base_identities[0]] - @staticmethod def _class_slots(base: str | None) -> str: """Store native wrapper state only on the root generated class.""" @@ -477,16 +486,6 @@ def _optional_keyword_collection_lines( ) return tuple(lines) - @staticmethod - def _class_base_name( - surface: ClassSurfacePlan | None, - class_names: dict[tuple[str, str], str], - ) -> str | None: - """Return the planned Python base-class name.""" - if surface is None or not surface.base_identities: - return None - return class_names[surface.base_identities[0]] - @staticmethod def _derived_property_python_lines(field: DerivedFieldPlan) -> tuple[str, ...]: """Build a property from completed getter and setter actions.""" @@ -642,7 +641,9 @@ def _module_proxy_ops_literal( derived_types=(example_derived,), classes=(example_surface,), ) - example_context = PythonSurfaceContext(frozenset(), frozenset(), frozenset()) + example_context = PythonSurfaceContext( + frozenset(), frozenset(), frozenset(), {example_identity: ((), example_derived)} + ) print("Rendered Python facade:") print(PythonSurfaceEmitter(example_context).emit(example_namespace, ())) diff --git a/prik/planning/planner.py b/prik/planning/planner.py index 8bdaa2037..e34a50473 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -567,7 +567,41 @@ def _namespace_plans( for path in namespace_paths ) self._complete_variable_support_namespaces(variables, namespaces) - return namespaces + return self._bases_first(namespaces) + + @staticmethod + def _bases_first(namespaces: tuple[NamespacePlan, ...]) -> tuple[NamespacePlan, ...]: + """Order namespaces so each comes after those defining the bases it extends. + + A namespace's classes are created when it is set up, and a class + extending one another module declares needs that base to exist. Path + order is kept wherever inheritance does not decide; namespaces whose + classes extend each other's are left in path order for validation to + reject. + """ + defined_in = { + surface.type_identity: namespace.python_path for namespace in namespaces for surface in namespace.classes + } + needs = { + namespace.python_path: { + defined_in[base] + for surface in namespace.classes + for base in surface.base_identities + if base in defined_in + } + - {namespace.python_path} + for namespace in namespaces + } + ordered: list[NamespacePlan] = [] + remaining = list(namespaces) + while remaining: + placed = {namespace.python_path for namespace in ordered} + ready = next((namespace for namespace in remaining if needs[namespace.python_path] <= placed), None) + if ready is None: + return (*ordered, *remaining) + ordered.append(ready) + remaining.remove(ready) + return tuple(ordered) @staticmethod def _complete_variable_support_namespaces( diff --git a/tests/fortran/derived_types/codegen/test_class_surfaces.py b/tests/fortran/derived_types/codegen/test_class_surfaces.py index 0abedef72..62857e54b 100644 --- a/tests/fortran/derived_types/codegen/test_class_surfaces.py +++ b/tests/fortran/derived_types/codegen/test_class_surfaces.py @@ -4,7 +4,10 @@ import pytest +from prik.parsers.fortran import parse_fortran_project +from prik.pipeline.build import _apply_source_python_exports, _merge_wrapper_modules from prik.pipeline.pyi import pyi_file_to_semantic_module +from prik.semantics.fortran2ir import fortran_project_to_semantic_modules from prik.policy.completion import complete_semantic_policies from prik.pipeline.wrapper import WrapperGenerator from prik.planning import WrapperPlanner @@ -67,3 +70,38 @@ def test_a_type_defined_in_two_namespaces_fails_before_emission(): with pytest.raises(ValueError, match="duplicate-derived-type-identity"): WrapperGenerator().generate(plan) + + +EXTENDING_ANOTHER_MODULE = """\ +module zeta_base + implicit none + type :: shape + integer :: sides = 0 + end type shape +end module zeta_base + +module alpha_child + use zeta_base, only: shape + implicit none + type, extends(shape) :: square + integer :: edge = 1 + end type square +end module alpha_child +""" + + +def test_a_namespace_is_planned_after_the_one_defining_its_base(tmp_path: Path): + """A class extending another namespace's type is created once its base exists. + + Path order would put `alpha_child` first; inheritance overrides it only + where it has to. + """ + (tmp_path / "project.f90").write_text(EXTENDING_ANOTHER_MODULE, encoding="utf-8") + modules = fortran_project_to_semantic_modules(parse_fortran_project(str(tmp_path))) + _apply_source_python_exports(modules) + module = _merge_wrapper_modules(modules, name="package") + complete_semantic_policies(module) + + plan = WrapperPlanner().build(module) + + assert [namespace.python_path for namespace in plan.namespaces] == [(), ("zeta_base",), ("alpha_child",)] diff --git a/tests/fortran/derived_types/end_to_end/test_types_across_modules.py b/tests/fortran/derived_types/end_to_end/test_types_across_modules.py index 864a1bd44..12658d73f 100644 --- a/tests/fortran/derived_types/end_to_end/test_types_across_modules.py +++ b/tests/fortran/derived_types/end_to_end/test_types_across_modules.py @@ -137,6 +137,38 @@ class default """ +BASE_SOURCE = """\ +module zeta_base + implicit none + type :: shape + integer :: sides = 0 + end type shape +contains + integer function sides_of(item) + class(shape), intent(in) :: item + sides_of = item%sides + end function sides_of +end module zeta_base +""" + +EXTENSION_SOURCE = """\ +module alpha_child + use zeta_base, only: shape + implicit none + type, extends(shape) :: square + integer :: edge = 1 + end type square +contains + function make_square(edge) result(out) + integer, intent(in) :: edge + type(square) :: out + out%sides = 4 + out%edge = edge + end function make_square +end module alpha_child +""" + + @pytest.fixture(scope="module") def modules(tmp_path_factory: pytest.TempPathFactory): """Build `shapes` and the `ops` module using its types once.""" @@ -220,3 +252,25 @@ def test_two_modules_may_each_declare_a_type_spelled_alike(tmp_path: Path): assert type(made) is first.Box assert made.value == 10 assert second.weigh(lambda: second.Box(weight=np.float64(3.5))) == 3.5 + + +def test_a_type_may_extend_one_another_module_declares(tmp_path: Path): + """The extension is a subclass of the base where the base is defined. + + `alpha_child` sorts before `zeta_base`, so its namespace is set up after + the base's only because inheritance orders them. Its class names the base + there instead of looking for it among its own. + """ + module, _ = _build_sources_and_import( + [("zeta_base.f90", BASE_SOURCE), ("alpha_child.f90", EXTENSION_SOURCE)], + tmp_path, + ) + base, child = module.zeta_base, module.alpha_child + + square = child.make_square(np.int32(3)) + + assert type(square) is child.Square + assert issubclass(child.Square, base.Shape) + assert (square.sides, square.edge) == (4, 3) + assert base.sides_of(square) == 4 + assert base.sides_of(base.Shape(sides=np.int32(2))) == 2 From 1886b13947457d93258d8256feeb0a3723c16bec Mon Sep 17 00:00:00 2001 From: said Date: Sun, 20 Sep 2026 00:28:46 +0100 Subject: [PATCH 90/96] codex: finish derived type contract naming --- CHANGELOG.md | 13 +++++ prik/codegen/c/binding.py | 28 +++++++--- prik/codegen/c/python_surface.py | 1 - prik/codegen/docstrings.py | 7 ++- prik/pipeline/wrapper.py | 3 +- prik/planning/models.py | 1 - prik/planning/planner.py | 1 - prik/policy/exports.py | 24 ++++++-- .../end_to_end/test_types_across_modules.py | 16 ++++++ .../policy/test_merged_contract_names.py | 56 +++++++++++++++++++ .../infrastructure/codegen/test_planner.py | 2 +- 11 files changed, 130 insertions(+), 22 deletions(-) create mode 100644 tests/fortran/derived_types/policy/test_merged_contract_names.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d4aca5618..369af407e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ release tags add a leading `v` to the package version. ## Unreleased +- A build named after one of its source modules -- the CLI's default, taken + from the first source -- no longer renames a type another of its modules + uses privately. Completing the merged build counted that use as an import, + although the build declares the type, so the class took `Box_2` in every + docstring while Python and the contracts published `Box`. A type the merged + module declares is now never one of its imports. + +- A rejected polymorphic argument names each accepted class the way its + contract declares it, rather than by a private name when the class is bound + under none; a rejected component value names its type the way the + component's declaration refers to it, as an argument's rejection already + did. + - A type may extend a type another module declares. The generated class looked its base up among its own namespace's classes, so such a build failed with a `KeyError`; it now names the base through the namespace defining it. diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index a07879129..1099c015d 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -977,6 +977,14 @@ def _type_class_name(self, type_identity: tuple[str, str]) -> str: """Return the name a type's class is defined under in its home.""" return self._type_homes[type_identity][1].definition_name + def _type_display_name(self, type_identity: tuple[str, str]) -> str: + """Return the name a message calls a type by: the one its contract declares. + + A type bound under no public name is defined under a private one, which + a message should not show. + """ + return self._type_homes[type_identity][1].contract_name + def _type_attribute(self, type_identity: tuple[str, str], attribute: str) -> str: """Return a new reference to one attribute of a type's home namespace.""" return f'PyObject_GetAttrString({self._type_namespace(type_identity)}, "{attribute}")' @@ -3531,7 +3539,7 @@ def _direct_nested_field_setter( return None body = ( *self._derived_owner_and_value_nodes(derived), - *self._exact_derived_type_check_nodes(field.derived.type_identity, "value_obj", field.name), + *self._exact_derived_type_check_nodes(field.derived, "value_obj", field.name), *self._derived_address_from_object_nodes(field.derived.backend_symbol, "value_obj", "value"), CExpressionStatement( CodeExpression( @@ -3579,7 +3587,7 @@ def _module_nested_member_setter( CExpressionStatement( CodeExpression('if (!PyArg_ParseTuple(args, "OO", &owner_obj, &value_obj)) return NULL') ), - *self._exact_derived_type_check_nodes(field.derived.type_identity, "value_obj", field.name), + *self._exact_derived_type_check_nodes(field.derived, "value_obj", field.name), *self._derived_address_from_object_nodes(field.derived.backend_symbol, "value_obj", "value"), CExpressionStatement( CodeExpression(f"{self._module_member_bridge_name(variable, member, 'set')}(value_address)") @@ -3709,12 +3717,16 @@ def _derived_address_from_object_nodes(self, type_symbol: str, object_name: str, CIf(CodeExpression(f"{address} == NULL"), body=(CReturn(CodeExpression("NULL")),)), ) - def _exact_derived_type_check_nodes(self, type_identity: tuple[str, str], object_name: str, label: str) -> tuple: - """Require the exact exported opaque class before a concrete field copy.""" + def _exact_derived_type_check_nodes(self, handoff: DerivedHandoffPlan, object_name: str, label: str) -> tuple: + """Require the exact exported opaque class before a concrete field copy. + + The message names the type the way the declaration being set refers to + it, which tells apart two types spelled alike where they are declared. + """ expected = f"{label}_expected_type" - type_name = self._type_class_name(type_identity) + type_name = handoff.type_name return ( - CDeclaration(expected, "PyObject *", CodeExpression(self._type_class(type_identity))), + CDeclaration(expected, "PyObject *", CodeExpression(self._type_class(handoff.type_identity))), CIf(CodeExpression(f"{expected} == NULL"), body=(CReturn(CodeExpression("NULL")),)), CIf( CodeExpression(f"Py_TYPE({object_name}) != (PyTypeObject *){expected}"), @@ -7144,7 +7156,7 @@ def _polymorphic_argument_nodes( CExpressionStatement( CodeExpression( f"{type_name} = " - f"{self._c_string_literal(self._type_class_name(variant.type_identity))}" + f"{self._c_string_literal(self._type_display_name(variant.type_identity))}" ) ), CExpressionStatement( @@ -7161,7 +7173,7 @@ def _polymorphic_argument_nodes( CExpressionStatement(CodeExpression(f"Py_DECREF({expected})")), ) ) - accepted = ", ".join(self._type_class_name(variant.type_identity) for variant in dispatch.variants) + accepted = ", ".join(self._type_display_name(variant.type_identity) for variant in dispatch.variants) nodes.append( CIf( CodeExpression(f"{code} == 0"), diff --git a/prik/codegen/c/python_surface.py b/prik/codegen/c/python_surface.py index 86c995e9c..b09a4a480 100644 --- a/prik/codegen/c/python_surface.py +++ b/prik/codegen/c/python_surface.py @@ -605,7 +605,6 @@ def _module_proxy_ops_literal( example_identity = ("state", "state_t") example_derived = DerivedTypePlan( owner_path="state.State", - type_name="State", type_identity=example_identity, backend_symbol="state_t", native_type_name="state_t", diff --git a/prik/codegen/docstrings.py b/prik/codegen/docstrings.py index bb548dc64..5222c05a3 100644 --- a/prik/codegen/docstrings.py +++ b/prik/codegen/docstrings.py @@ -941,13 +941,14 @@ def _published_class_name(self, transfer) -> str: """Return the name a namespace publishes one wrapped type under. The type is found by its identity: two modules may each declare a type - spelled alike, and each is published under its own name. + spelled alike, and each is published under its own name. Rendering runs + before the plan is validated, so a reference to a type the plan does not + define keeps its semantic name here and is rejected by validation. """ derived = getattr(transfer, "derived", None) handoff = getattr(derived, "handoff", derived) identity = handoff.type_identity if handoff is not None else transfer.derived_type_identity - index = getattr(self, "_published_class_names", {}) - return index.get(identity, str(transfer.semantic_type_name)) + return self._published_class_names.get(identity, str(transfer.semantic_type_name)) def _base_type(self, transfer) -> str: """Map one completed transfer family and storage facet to public type text. diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index 149acb052..08f3908f0 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -890,8 +890,7 @@ def _one_derived_type_diagnostics(self, derived) -> tuple[WrapperPlanDiagnostic, identity = ( (self._diagnostic(derived.owner_path, "incomplete-derived-type-identity", derived),) if ( - not derived.type_name - or not derived.native_type_name + not derived.native_type_name or not derived.native_scope or derived.type_identity != (derived.native_scope, derived.native_type_name) ) diff --git a/prik/planning/models.py b/prik/planning/models.py index 1e75d2358..9f1e814b5 100644 --- a/prik/planning/models.py +++ b/prik/planning/models.py @@ -343,7 +343,6 @@ class DerivedTypePlan(StageRecord): """ owner_path: str - type_name: str type_identity: tuple[str, str] backend_symbol: str native_type_name: str diff --git a/prik/planning/planner.py b/prik/planning/planner.py index e34a50473..8b415b044 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -810,7 +810,6 @@ def _derived_type_plan( planned_fields = tuple(self._derived_field_plan(field) for field in (fields or policy.fields)) return DerivedTypePlan( owner_path=policy.owner_path, - type_name=policy.type_name, type_identity=policy.type_identity, backend_symbol=self._derived_backend_symbol(policy.type_identity), native_type_name=policy.native_type_name, diff --git a/prik/policy/exports.py b/prik/policy/exports.py index 7d98fedec..3c58a6fb6 100644 --- a/prik/policy/exports.py +++ b/prik/policy/exports.py @@ -519,7 +519,11 @@ def _imported_local_names(module: models.SemanticModule, declared: set[tuple[str """Yield ``(local name, category)`` for each name a declaration reads from another module.""" for semantic_type in models._module_semantic_types(module): reference = imported_type_reference(semantic_type) - if reference is not None and not reference.procedure_local: + if ( + reference is not None + and not reference.procedure_local + and declaration_identity(reference.module, reference.name) not in declared + ): yield reference.local, "class" for callable_reference in _expression_callables(semantic_type): identity = _callable_identity(callable_reference) @@ -609,15 +613,25 @@ def _complete_type_reference_names(module: models.SemanticModule, imported: dict imported one under the name the module imports it by, so an annotation, the import binding its name, and ``__all__`` write one spelling. """ - declared = {str(cls.name): models.completed_contract_name(cls) for cls in _all_classes(module.classes)} + classes = tuple(_all_classes(module.classes)) + declared = {str(cls.name): models.completed_contract_name(cls) for cls in classes} + # A build merges modules, so a type one of them imports can be declared here. + declared_by_identity = { + declaration_identity(cls.origin.native_scope or module.name, cls.native_name or cls.name): ( + models.completed_contract_name(cls) + ) + for cls in classes + } for semantic_type in models._module_semantic_types(module): reference = imported_type_reference(semantic_type) if reference is None: completed = contract_name_for_source(declared, semantic_type.name) - elif not reference.procedure_local: - completed = contract_name_for_source(imported, reference.local) - else: + elif reference.procedure_local: continue + elif declaration_identity(reference.module, reference.name) in declared_by_identity: + completed = declared_by_identity[declaration_identity(reference.module, reference.name)] + else: + completed = contract_name_for_source(imported, reference.local) if completed is not None: semantic_type.metadata[models.CONTRACT_NAME_METADATA] = completed # A base is named, not annotated: the class it names is declared here or imported. diff --git a/tests/fortran/derived_types/end_to_end/test_types_across_modules.py b/tests/fortran/derived_types/end_to_end/test_types_across_modules.py index 12658d73f..82fd4370b 100644 --- a/tests/fortran/derived_types/end_to_end/test_types_across_modules.py +++ b/tests/fortran/derived_types/end_to_end/test_types_across_modules.py @@ -217,6 +217,22 @@ def test_a_polymorphic_argument_accepts_each_declaring_module_class(modules): assert ops.describe(shapes.Box()) == 1 assert ops.describe(shapes.Tagged_Box()) == 2 + # A rejection names each accepted class the way its contract declares it. + with pytest.raises(TypeError, match=r"wrapper type: Tagged_Box, Box$"): + ops.describe(ops.Holder()) + + +def test_documentation_names_another_module_type_as_it_is_published(modules): + """The build is named after its first source, `shapes`, like that module. + + Completing the merged build counted `ops`'s use of `box` as an import even + though the build declares `box`, so the class took `Box_2` and every + docstring naming it disagreed with the published `Box`. + """ + shapes, ops = modules + + assert ops.boxed.__doc__.splitlines()[0] == "boxed(v) -> Box" + assert shapes.Box.__doc__.splitlines()[0] == "Box" def test_a_generic_dispatches_on_the_declaring_module_class(modules): diff --git a/tests/fortran/derived_types/policy/test_merged_contract_names.py b/tests/fortran/derived_types/policy/test_merged_contract_names.py new file mode 100644 index 000000000..3495d0ccf --- /dev/null +++ b/tests/fortran/derived_types/policy/test_merged_contract_names.py @@ -0,0 +1,56 @@ +"""A build names a type once, however many of its modules use it. + +A build merges its source modules into one, so a type one of them imports from +another is declared by the merged module itself. It is not an import there, +and it must not compete with its own declaration for a name. +""" + +from pathlib import Path + +from prik.parsers.fortran import parse_fortran_project +from prik.pipeline.build import _apply_source_python_exports, _merge_wrapper_modules +from prik.policy.exports import complete_python_export_policy +from prik.semantics import models +from prik.semantics.fortran2ir import fortran_project_to_semantic_modules + +SOURCES = """\ +module shapes + implicit none + type :: box + integer :: value = 0 + end type box +end module shapes + +module ops + use shapes, only: box + implicit none + private + public :: boxed +contains + function boxed(v) result(out) + integer, intent(in) :: v + type(box) :: out + out%value = v + end function boxed +end module ops +""" + + +def test_a_merged_build_names_a_type_its_modules_share_once(tmp_path: Path): + """Named like the module declaring the type, the build still spells it `Box`. + + `ops` uses `box` without publishing it. Counting that use as an import put + a second `Box` in the ledger ahead of the declaration, which took `Box_2`. + """ + (tmp_path / "project.f90").write_text(SOURCES, encoding="utf-8") + modules = fortran_project_to_semantic_modules(parse_fortran_project(str(tmp_path))) + _apply_source_python_exports(modules) + merged = _merge_wrapper_modules(modules, name="shapes") + + complete_python_export_policy(merged) + + box = next(item for item in merged.classes if item.name == "box") + boxed = next(item for item in merged.functions if item.name == "boxed") + assert models.completed_contract_name(box) == "Box" + assert boxed.return_type.metadata[models.CONTRACT_NAME_METADATA] == "Box" + assert "box" not in merged.metadata[models.CONTRACT_IMPORT_NAMES_METADATA] diff --git a/tests/fortran/infrastructure/codegen/test_planner.py b/tests/fortran/infrastructure/codegen/test_planner.py index e03fb84d9..609520056 100644 --- a/tests/fortran/infrastructure/codegen/test_planner.py +++ b/tests/fortran/infrastructure/codegen/test_planner.py @@ -246,7 +246,7 @@ def move(self, dx: Float64) -> None: ... generated = WrapperGenerator().generate(plan) planned_outer, planned_inner = plan.namespaces[0].derived_types - assert (planned_outer.type_name, planned_inner.type_name) == ("outer", "inner") + assert (planned_outer.native_type_name, planned_inner.native_type_name) == ("outer", "inner") # The nested type is defined beside its parent and bound on it, not here. assert planned_outer.python_names == ("outer",) assert planned_inner.python_names == () From d347264389a5612e2698ac0c687d447b7ad78f28 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 20 Sep 2026 04:27:26 +0100 Subject: [PATCH 91/96] codex: link variable publications to their owner plan --- docs/developer/packages/planning.md | 13 +++++++------ prik/codegen/c/binding.py | 18 ++++-------------- prik/codegen/docstrings.py | 11 ++++------- prik/pipeline/wrapper.py | 8 ++++---- prik/planning/models.py | 8 ++++---- prik/planning/planner.py | 2 +- .../infrastructure/codegen/test_planner.py | 11 ++++++----- 7 files changed, 30 insertions(+), 41 deletions(-) diff --git a/docs/developer/packages/planning.md b/docs/developer/packages/planning.md index 3036d4af8..e9f3763ba 100644 --- a/docs/developer/packages/planning.md +++ b/docs/developer/packages/planning.md @@ -87,12 +87,13 @@ of the original Fortran procedure. One module-level `ModuleVariablePlan` owns each declaring native variable and its completed getter, setter, ownership, descriptor, array, and derived-object mechanisms. Its owner path is the declaring native module and name, independent -of Python publication. A namespace-level `ModuleVariablePublicationPlan` -records only the Python names that point to that canonical plan. Re-exporting -module state therefore adds publication records without changing variable -identity or adding accessors, support procedures, initialization, allocation -state, or pointer state. Parameters use the same structure while retaining -constant-value lowering. +of Python publication. A namespace-level `ModuleVariablePublicationPlan` holds +a direct reference to that canonical plan plus the Python names published in +the namespace. Re-exporting module state therefore adds publication records +without resolving ownership from a second key, changing variable identity, or +adding accessors, support procedures, initialization, allocation state, or +pointer state. Parameters use the same structure while retaining constant-value +lowering. `NativeEntrypointModulePlan.support_procedures` is the authoritative registry for externally linked generated helper callables that are not ordinary wrapped diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 1099c015d..0edeec15c 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -14481,7 +14481,7 @@ def _module_property_support( ), reject_replacement=(variable.binding.setter_action is SetterAction.REJECT_REPLACEMENT), ) - for variable, publication in self._variable_publications(module, namespace) + for variable, publication in self._variable_publications(namespace) if variable.binding.getter_action not in { ModuleGetterAction.CONSTANT_VALUE, @@ -15626,7 +15626,7 @@ def _module_constant_nodes( nodes = [] index = 0 namespace_symbol = self._namespace_symbol(namespace) - for variable, publication in self._variable_publications(plan, namespace): + for variable, publication in self._variable_publications(namespace): if variable.binding.getter_action not in { ModuleGetterAction.CONSTANT_VALUE, ModuleGetterAction.NATIVE_CONSTANT_VALUE, @@ -15849,20 +15849,10 @@ def _variables(self, plan: ModulePlan) -> tuple[ModuleVariablePlan, ...]: def _variable_publications( self, - plan: ModulePlan, namespace: NamespacePlan, ) -> tuple[tuple[ModuleVariablePlan, ModuleVariablePublicationPlan], ...]: - """Resolve namespace publications to their one native variable plan.""" - variables = {variable.owner_path: variable for variable in self._variables(plan)} - resolved = [] - for publication in namespace.variable_publications: - variable = variables.get(publication.variable_owner_path) - if variable is None: - raise ValueError( - f"Module-variable publication references missing plan {publication.variable_owner_path!r}" - ) - resolved.append((variable, publication)) - return tuple(resolved) + """Pair namespace publications with their canonical variable plans.""" + return tuple((publication.variable, publication) for publication in namespace.variable_publications) def _support_variables( self, diff --git a/prik/codegen/docstrings.py b/prik/codegen/docstrings.py index 5222c05a3..029af2b48 100644 --- a/prik/codegen/docstrings.py +++ b/prik/codegen/docstrings.py @@ -99,11 +99,9 @@ def render(self, plan: ModulePlan) -> ModulePlan: for namespace in plan.namespaces for derived in namespace.derived_types } - self._module_variables_by_owner = {variable.owner_path: variable for variable in plan.variables} - # A publication can sort before the namespace that owns its canonical - # variable plan. Render every canonical variable first so namespace - # summaries only read completed documentation from that owner. - for variable in self._module_variables_by_owner.values(): + # Render every canonical variable first so namespace summaries only + # read completed documentation from that owner. + for variable in plan.variables: self._render_module_variable(variable) for namespace in plan.namespaces: self._render_namespace(plan.owner_path, namespace) @@ -125,8 +123,7 @@ def _render_namespace(self, module_name: str, namespace: NamespacePlan) -> None: if namespace.docstring is None: variable_publications = tuple( - (self._module_variables_by_owner[publication.variable_owner_path], publication) - for publication in namespace.variable_publications + (publication.variable, publication) for publication in namespace.variable_publications ) namespace.docstring = self.namespace( module_name, diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index 08f3908f0..a60e2d093 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -1067,7 +1067,7 @@ def _module_variable_publication_diagnostics( plan: ModulePlan, ) -> tuple[WrapperPlanDiagnostic, ...]: """Validate that every publication references one canonical variable plan.""" - owners = {variable.owner_path for variable in plan.variables} + variable_ids = {id(variable) for variable in plan.variables} diagnostics = [] namespace_paths = {namespace.python_path for namespace in plan.namespaces} diagnostics.extend( @@ -1081,12 +1081,12 @@ def _module_variable_publication_diagnostics( ) for namespace in plan.namespaces: for publication in namespace.variable_publications: - if publication.variable_owner_path not in owners: + if id(publication.variable) not in variable_ids: diagnostics.append( self._diagnostic( namespace.owner_path, "missing-module-variable-publication-owner", - publication.variable_owner_path, + publication.variable.owner_path, ) ) if not publication.python_names: @@ -1094,7 +1094,7 @@ def _module_variable_publication_diagnostics( self._diagnostic( namespace.owner_path, "empty-module-variable-publication", - publication.variable_owner_path, + publication.variable.owner_path, ) ) return tuple(diagnostics) diff --git a/prik/planning/models.py b/prik/planning/models.py index 9f1e814b5..355e4bc2c 100644 --- a/prik/planning/models.py +++ b/prik/planning/models.py @@ -826,12 +826,12 @@ class ModuleVariablePlan(StageRecord): class ModuleVariablePublicationPlan(StageRecord): """Publish one existing module-variable plan in a Python namespace. - ``variable_owner_path`` identifies the sole plan that owns native access, - storage, initialization, and support procedures. This record adds only - Python names in one namespace; it never creates another variable plan. + ``variable`` is the sole plan that owns native access, storage, + initialization, and support procedures. This record adds only Python names + in one namespace; it never creates another variable plan. """ - variable_owner_path: str + variable: ModuleVariablePlan python_names: tuple[str, ...] diff --git a/prik/planning/planner.py b/prik/planning/planner.py index 8b415b044..c9d56b6b5 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -1253,7 +1253,7 @@ def _module_variables_and_publications( for namespace, python_names in exports_by_namespace.items(): publications[namespace].append( ModuleVariablePublicationPlan( - variable_owner_path=plan.owner_path, + variable=plan, python_names=tuple(python_names), ) ) diff --git a/tests/fortran/infrastructure/codegen/test_planner.py b/tests/fortran/infrastructure/codegen/test_planner.py index 609520056..4d4fb541b 100644 --- a/tests/fortran/infrastructure/codegen/test_planner.py +++ b/tests/fortran/infrastructure/codegen/test_planner.py @@ -85,15 +85,16 @@ def test_planner_keeps_one_module_variable_plan_for_multiple_publications(): variables = list(plan.variables) publications = [ - (namespace.python_path, publication.variable_owner_path, publication.python_names) + (namespace.python_path, publication.variable, publication.python_names) for namespace in plan.namespaces for publication in namespace.variable_publications ] assert len(variables) == 1 - assert publications == [ - ((), variables[0].owner_path, ("counter",)), - (("facade",), variables[0].owner_path, ("counter",)), + assert [(path, names) for path, _variable, names in publications] == [ + ((), ("counter",)), + (("facade",), ("counter",)), ] + assert all(variable is variables[0] for _path, variable, _names in publications) def test_module_variable_owner_is_its_native_identity_not_a_publication_path(): @@ -126,7 +127,7 @@ def planned_owner(*namespaces: str): for procedure in facade_and_api.entrypoint.support_procedures ] assert { - (namespace.python_path, publication.variable_owner_path) + (namespace.python_path, publication.variable.owner_path) for namespace in facade_and_api.namespaces for publication in namespace.variable_publications } == { From ea729f899e6d70c0d4ed8428beb86920673fc968 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 20 Sep 2026 04:40:32 +0100 Subject: [PATCH 92/96] codex: keep edited variable plan graphs coherent --- .../test_scalar_module_variable_lowering.py | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/tests/fortran/modules/codegen/test_scalar_module_variable_lowering.py b/tests/fortran/modules/codegen/test_scalar_module_variable_lowering.py index 2ad3cb240..3445fe321 100644 --- a/tests/fortran/modules/codegen/test_scalar_module_variable_lowering.py +++ b/tests/fortran/modules/codegen/test_scalar_module_variable_lowering.py @@ -78,10 +78,20 @@ def _source(artifacts, suffix: str) -> str: def _replace_variable(plan, python_name: str, edit): - variables = tuple( - edit(variable) if variable.bridge.native_name == python_name else variable for variable in plan.variables + current = next(variable for variable in plan.variables if variable.bridge.native_name == python_name) + replacement = edit(current) + variables = tuple(replacement if variable is current else variable for variable in plan.variables) + namespaces = tuple( + replace( + namespace, + variable_publications=tuple( + replace(publication, variable=replacement) if publication.variable is current else publication + for publication in namespace.variable_publications + ), + ) + for namespace in plan.namespaces ) - return replace(plan, variables=variables) + return replace(plan, variables=variables, namespaces=namespaces) def test_module_variable_plan_contains_only_completed_dispatch_facts(): @@ -308,14 +318,13 @@ def test_missing_generated_support_procedure_fails_before_lowering(): def test_bridge_local_module_target_edit_does_not_change_the_c_boundary(): plan = _plan() baseline = _source(WrapperGenerator().generate(plan), ".c") - counter = next(variable for variable in plan.variables if variable.bridge.native_name == "counter") - edited_counter = replace( - counter, - bridge=replace(counter.bridge, native_name="counter_alternate"), - ) - edited = replace( + edited = _replace_variable( plan, - variables=tuple(edited_counter if variable is counter else variable for variable in plan.variables), + "counter", + lambda variable: replace( + variable, + bridge=replace(variable.bridge, native_name="counter_alternate"), + ), ) artifacts = WrapperGenerator().generate(edited) @@ -325,12 +334,13 @@ def test_bridge_local_module_target_edit_does_not_change_the_c_boundary(): def test_generator_rejects_python_module_setter_without_bridge_handoff(): - plan = _plan() - counter = next(variable for variable in plan.variables if variable.bridge.native_name == "counter") - invalid_counter = replace(counter, entrypoint=replace(counter.entrypoint, setter_role=None)) - invalid = replace( - plan, - variables=tuple(invalid_counter if variable is counter else variable for variable in plan.variables), + invalid = _replace_variable( + _plan(), + "counter", + lambda variable: replace( + variable, + entrypoint=replace(variable.entrypoint, setter_role=None), + ), ) with pytest.raises(ValueError, match="missing-module-setter-role"): From 8e895645a8a97b5c24eaaf08f79cd6955ade4266 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 20 Sep 2026 04:52:49 +0100 Subject: [PATCH 93/96] codex: isolate the direct C contract module name --- tests/c/primitive_scalars/end_to_end/test_direct_c_runtime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/c/primitive_scalars/end_to_end/test_direct_c_runtime.py b/tests/c/primitive_scalars/end_to_end/test_direct_c_runtime.py index c95929ba0..28f31a0b3 100644 --- a/tests/c/primitive_scalars/end_to_end/test_direct_c_runtime.py +++ b/tests/c/primitive_scalars/end_to_end/test_direct_c_runtime.py @@ -34,7 +34,7 @@ def test_c_source_build_calls_renamed_user_symbol_without_a_fortran_adapter(tmp_ @pytest.mark.skipif(shutil.which("cc") is None, reason="requires a C compiler") def test_c_native_language_is_explicit_for_a_source_free_pyi_contract(tmp_path: Path): - contract = tmp_path / "contract.pyi" + contract = tmp_path / "direct_c_contract.pyi" contract.write_text( """from prik.contracts import Float64, Int, bind From c92462bdc3ab9c12bb4c1390bc5537b071e949a4 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 20 Sep 2026 12:05:56 +0100 Subject: [PATCH 94/96] codex: preserve optional callback presence --- CHANGELOG.md | 3 + docs/user/guide/callbacks.md | 22 ++- prik/codegen/c/binding.py | 93 +++++++-- prik/codegen/fortran/bridge.py | 156 ++++++++++++++-- prik/pipeline/wrapper.py | 5 +- prik/planning/models.py | 5 + prik/planning/planner.py | 4 + prik/policy/construction.py | 38 +++- prik/policy/models.py | 12 ++ .../codegen/test_callback_planning.py | 17 +- .../end_to_end/test_optional_callbacks.py | 176 ++++++++++++++++++ .../callbacks/policy/test_callback_policy.py | 26 ++- .../test_fortran_callback_semantics.py | 32 ++++ .../policy/test_optional_policy.py | 7 +- 14 files changed, 547 insertions(+), 49 deletions(-) create mode 100644 tests/fortran/callbacks/end_to_end/test_optional_callbacks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 369af407e..f94345441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ release tags add a leading `v` to the package version. ## Unreleased +- Optional Fortran callbacks and optional reference dummies inside callback + interfaces preserve `PRESENT()` through source and generated-contract builds. + - A build named after one of its source modules -- the CLI's default, taken from the first source -- no longer renames a type another of its modules uses privately. Completing the merged build counted that use as an import, diff --git a/docs/user/guide/callbacks.md b/docs/user/guide/callbacks.md index 24e97b5e6..ae5a08dfd 100644 --- a/docs/user/guide/callbacks.md +++ b/docs/user/guide/callbacks.md @@ -79,6 +79,22 @@ api.apply(lambda value: np.float64(3.0 * value), np.float64(2.5)) The lambda receives converted Python objects, not `Addr(...)` markers. +Optional procedure dummies use the same Python spelling as other optional +arguments. Omit the callable or pass `None` to make Fortran observe +`present(callback) == .false.`: + +```python +api.run(np.int32(4)) +api.run(np.int32(4), None) +api.run(np.int32(4), report) +``` + +Optional dummies inside a callback prototype arrive at the Python callable as +`None` when the native callback invocation omits them. The callable keeps the +prototype's full positional argument list, so one callable such as +`report(value, status=None)` handles both `call report(value)` and +`call report(value, status)`. + --- ## Small Example @@ -309,10 +325,6 @@ The current callback contract does not support: - Stored callbacks, persistent callbacks, procedure pointers, or callbacks invoked after the wrapped call returns. Pass the callable into each wrapped call that needs it. -- Optional callback procedure arguments. Expose a separate native entry point - for the no-callback path, or require the callback argument. -- Optional arguments inside a `@prototype`. Pass an explicit value, sentinel, or - presence flag instead. - Pure callback prototypes. A Python callback adapter calls the Python runtime, so it cannot satisfy a pure Fortran procedure contract. In particular, one pure prototype cannot be used both as a callback annotation and as a called @@ -323,6 +335,8 @@ The current callback contract does not support: - 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; an array *argument* may be assumed-shape. +- Optional callback dummies passed by `value`. Use a reference dummy so absence + has a C-interoperable null-pointer representation. - 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/codegen/c/binding.py b/prik/codegen/c/binding.py index 0edeec15c..11ec4a24b 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -30,6 +30,7 @@ ArrayEntrypointABI, ArrayPythonLayout, CallbackABIKind, + CallbackOptionalityAction, CallbackResultAction, CallbackTransferAction, ClassConstructorKind, @@ -1018,6 +1019,40 @@ def _callback_python_argument_nodes( target: str, ) -> tuple: """Dispatch one completed Python projection into a small conversion leaf.""" + if transfer.optionality is CallbackOptionalityAction.NULL_DATA_POINTER: + present_target = f"{target}_present" + present_nodes = self._callback_required_python_argument_nodes( + callback, + transfer, + position, + present_target, + ) + base = self._callback_parameter_base_name(transfer) + return ( + CDeclaration(target, "PyObject *", CodeExpression("NULL")), + CIf( + CodeExpression(f"{base}_data == NULL"), + body=( + CExpressionStatement(CodeExpression("Py_INCREF(Py_None)")), + CExpressionStatement(CodeExpression(f"{target} = Py_None")), + ), + else_body=( + *present_nodes, + CExpressionStatement(CodeExpression(f"{target} = {present_target}")), + ), + ), + self._callback_abort_if_null(callback, target, "failed to convert callback argument"), + ) + return self._callback_required_python_argument_nodes(callback, transfer, position, target) + + def _callback_required_python_argument_nodes( + self, + callback: CallbackHandoffPlan, + transfer: CallbackTransferPlan, + position: int, + target: str, + ) -> tuple: + """Project one callback dummy whose data pointer is known to be present.""" match transfer.python_action: case PythonBarrierAction.SCALAR_VALUE: nodes = self._callback_scalar_value_nodes(transfer, target) @@ -6873,10 +6908,19 @@ def _lower_argument_callback( ) -> tuple[CDeclaration | CIf, ...]: """Validate an immediate Python callable before any context is retained.""" names = context.arguments[plan.owner_path] + optional = plan.binding.optional_mode is OptionalMode.NULLABLE_VALUE return ( - CDeclaration(names.object_name, "PyObject *"), + CDeclaration( + names.object_name, + "PyObject *", + CodeExpression("Py_None") if optional else None, + ), CIf( - CodeExpression(f"!PyCallable_Check({names.object_name})"), + CodeExpression( + f"{names.object_name} != Py_None && !PyCallable_Check({names.object_name})" + if optional + else f"!PyCallable_Check({names.object_name})" + ), body=( CExpressionStatement( CodeExpression( @@ -6897,6 +6941,7 @@ def _callback_context_declarations( CDeclaration( self._callback_context_name(argument), argument.callback.binding.context_type_symbol, + CodeExpression("{0}"), ) for argument in plan.arguments if argument.callback is not None @@ -6906,13 +6951,13 @@ def _callback_context_push_nodes( self, plan: FunctionPlan, context: _CFunctionContext, - ) -> tuple[CExpressionStatement, ...]: + ) -> tuple[CExpressionStatement | CIf, ...]: """Retain callables and publish each stack context immediately before entry.""" - return tuple( - node - for argument in plan.arguments - if argument.callback is not None - for node in ( + nodes = [] + for argument in plan.arguments: + if argument.callback is None: + continue + body = ( CExpressionStatement( CodeExpression( f"{self._callback_context_name(argument)}.callable = " @@ -6938,18 +6983,22 @@ def _callback_context_push_nodes( ) ), ) - ) + if argument.binding.optional_mode is OptionalMode.NULLABLE_VALUE: + name = context.arguments[argument.owner_path].object_name + nodes.append(CIf(CodeExpression(f"{name} != Py_None"), body=body)) + else: + nodes.extend(body) + return tuple(nodes) def _callback_context_pop_nodes( self, plan: FunctionPlan, - ) -> tuple[CExpressionStatement, ...]: + ) -> tuple[CExpressionStatement | CIf, ...]: """Restore nested stacks and release retained objects in reverse order.""" arguments = tuple(argument for argument in plan.arguments if argument.callback is not None) - return tuple( - node - for argument in reversed(arguments) - for node in ( + nodes = [] + for argument in reversed(arguments): + body = ( CExpressionStatement( CodeExpression( f"{argument.callback.binding.context_current_symbol} = " @@ -6961,7 +7010,16 @@ def _callback_context_pop_nodes( ), CExpressionStatement(CodeExpression(f"Py_DECREF({self._callback_context_name(argument)}.callable)")), ) - ) + if argument.binding.optional_mode is OptionalMode.NULLABLE_VALUE: + nodes.append( + CIf( + CodeExpression(f"{self._callback_context_name(argument)}.callable != NULL"), + body=body, + ) + ) + else: + nodes.extend(body) + return tuple(nodes) @staticmethod def _callback_context_name(argument: ArgumentTransferPlan) -> str: @@ -14002,7 +14060,10 @@ def _entrypoint_argument_values( if plan.callback is not None: if not plan.entrypoint.pass_callback_parameter: return () - return (plan.callback.entrypoint.support_procedure.symbol_name,) + symbol = plan.callback.entrypoint.support_procedure.symbol_name + if plan.entrypoint.optional_mode is OptionalMode.NULLABLE_VALUE: + return (f"{names.object_name} != Py_None ? {symbol} : NULL",) + return (symbol,) if plan.entrypoint.handoff_mode is ArgumentHandoffMode.CHARACTER_BUFFER: return self._string_entrypoint_argument_values(plan, names, passing=passing) if plan.entrypoint.handoff_mode is ArgumentHandoffMode.ARRAY_BUFFER: diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index fc8f47e12..24c8b94ac 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -28,6 +28,7 @@ ArrayEntrypointABI, BridgeDataAction, CallbackABIKind, + CallbackOptionalityAction, CallbackResultAction, CallbackTransferAction, ClassInvocationKind, @@ -895,6 +896,8 @@ def _callback_native_parameter(self, transfer: CallbackTransferPlan) -> FortranP attributes.append("value") if transfer.intent is not None: attributes.append(f"intent({transfer.intent})") + if transfer.optionality is CallbackOptionalityAction.NULL_DATA_POINTER: + attributes.append("optional") if transfer.abi is not CallbackABIKind.VALUE and transfer.adapter_action in { CallbackTransferAction.BORROW_READ_ONLY, CallbackTransferAction.BORROW_WRITABLE, @@ -917,6 +920,14 @@ def _callback_standalone_adapter_uses( native_imports = self._callback_native_imports(callback) adapter_imports = ( *(("c_loc",) if any(transfer.abi is not CallbackABIKind.VALUE for transfer in callback.arguments) else ()), + *( + ("c_null_ptr",) + if any( + transfer.optionality is CallbackOptionalityAction.NULL_DATA_POINTER + for transfer in callback.arguments + ) + else () + ), *( ("c_f_pointer",) if callback.result.action @@ -975,50 +986,90 @@ def _callback_transfer_declarations( }: attributes = ["target"] if transfer.rank: - attributes.append(f"dimension({self._callback_storage_shape(transfer)})") + if transfer.optionality is CallbackOptionalityAction.NULL_DATA_POINTER: + attributes.extend(("allocatable", self._array_dimension_attribute(transfer.rank))) + else: + attributes.append(f"dimension({self._callback_storage_shape(transfer)})") declarations.append( FortranDeclaration( self._callback_storage_name(transfer), - self._callback_native_type(transfer), + self._callback_abi_storage_type(transfer), tuple(attributes), ) ) + if transfer.optionality is CallbackOptionalityAction.NULL_DATA_POINTER: + if transfer.abi is CallbackABIKind.DATA_AND_SHAPE: + declarations.extend( + FortranDeclaration(f"{base}_extent_{axis}", "integer(c_int64_t)") for axis in range(transfer.rank) + ) + elif transfer.abi is CallbackABIKind.DATA_AND_LENGTH: + declarations.append(FortranDeclaration(f"{base}_length", "integer(c_int64_t)")) return tuple(declarations) def _callback_transfer_preparation( self, transfer: CallbackTransferPlan, - ) -> tuple[FortranAssignment, ...]: + ) -> tuple[FortranAssignment | FortranAllocate | FortranIf, ...]: """Copy into call-local storage when selected, then expose its address.""" if transfer.abi is CallbackABIKind.VALUE: return () base = self._callback_parameter_base_name(transfer) storage = self._callback_address_source(transfer) - statements = [] + statements: list[FortranAssignment | FortranAllocate] = [] + if ( + transfer.optionality is CallbackOptionalityAction.NULL_DATA_POINTER + and transfer.rank + and transfer.adapter_action + in { + CallbackTransferAction.COPY_IN, + CallbackTransferAction.COPY_OUT, + CallbackTransferAction.COPY_IN_OUT, + } + ): + statements.append( + FortranAllocate( + storage, + tuple(CodeExpression(f"size({base}, dim={axis + 1})") for axis in range(transfer.rank)), + ) + ) if transfer.adapter_action in { CallbackTransferAction.COPY_IN, CallbackTransferAction.COPY_IN_OUT, }: statements.append(FortranAssignment(storage, CodeExpression(base))) statements.append(FortranAssignment(f"{base}_data", CodeExpression(f"c_loc({storage})"))) - return tuple(statements) + statements.extend(self._callback_optional_metadata_assignments(transfer, storage)) + if transfer.optionality is CallbackOptionalityAction.REQUIRED: + return tuple(statements) + initializers = [FortranAssignment(f"{base}_data", CodeExpression("c_null_ptr"))] + initializers.extend(self._callback_optional_metadata_initializers(transfer)) + return ( + *initializers, + FortranIf(CodeExpression(f"present({base})"), body=tuple(statements)), + ) def _callback_transfer_writeback( self, transfer: CallbackTransferPlan, - ) -> tuple[FortranAssignment, ...]: + ) -> tuple[FortranAssignment | FortranIf, ...]: """Copy writable callback storage back to the native dummy exactly once.""" if transfer.adapter_action not in { CallbackTransferAction.COPY_OUT, CallbackTransferAction.COPY_IN_OUT, }: return () - return ( - FortranAssignment( - self._callback_parameter_base_name(transfer), - CodeExpression(self._callback_storage_name(transfer)), - ), + assignment = FortranAssignment( + self._callback_parameter_base_name(transfer), + CodeExpression(self._callback_storage_name(transfer)), ) + if transfer.optionality is CallbackOptionalityAction.NULL_DATA_POINTER: + return ( + FortranIf( + CodeExpression(f"present({self._callback_parameter_base_name(transfer)})"), + body=(assignment,), + ), + ) + return (assignment,) def _callback_invocation( self, @@ -1052,12 +1103,19 @@ def _callback_c_argument_expressions( if transfer.abi is CallbackABIKind.VALUE: return (CodeExpression(base),) if transfer.abi is CallbackABIKind.DATA_AND_SHAPE: + if transfer.optionality is CallbackOptionalityAction.NULL_DATA_POINTER: + return ( + CodeExpression(f"{base}_data"), + *(CodeExpression(f"{base}_extent_{axis}") for axis in range(transfer.rank)), + ) storage = self._callback_address_source(transfer) return ( CodeExpression(f"{base}_data"), *(CodeExpression(f"size({storage}, dim={axis + 1}, kind=c_int64_t)") for axis in range(transfer.rank)), ) if transfer.abi is CallbackABIKind.DATA_AND_LENGTH: + if transfer.optionality is CallbackOptionalityAction.NULL_DATA_POINTER: + return (CodeExpression(f"{base}_data"), CodeExpression(f"{base}_length")) storage = self._callback_address_source(transfer) return ( CodeExpression(f"{base}_data"), @@ -1065,6 +1123,47 @@ def _callback_c_argument_expressions( ) return (CodeExpression(f"{base}_data"),) + def _callback_optional_metadata_initializers( + self, + transfer: CallbackTransferPlan, + ) -> tuple[FortranAssignment, ...]: + """Initialize metadata paired with an absent callback dummy.""" + base = self._callback_parameter_base_name(transfer) + if transfer.abi is CallbackABIKind.DATA_AND_SHAPE: + return tuple( + FortranAssignment(f"{base}_extent_{axis}", CodeExpression("0_c_int64_t")) + for axis in range(transfer.rank) + ) + if transfer.abi is CallbackABIKind.DATA_AND_LENGTH: + return (FortranAssignment(f"{base}_length", CodeExpression("0_c_int64_t")),) + return () + + def _callback_optional_metadata_assignments( + self, + transfer: CallbackTransferPlan, + storage: str, + ) -> tuple[FortranAssignment, ...]: + """Measure metadata only after an optional callback dummy is present.""" + if transfer.optionality is CallbackOptionalityAction.REQUIRED: + return () + base = self._callback_parameter_base_name(transfer) + if transfer.abi is CallbackABIKind.DATA_AND_SHAPE: + return tuple( + FortranAssignment( + f"{base}_extent_{axis}", + CodeExpression(f"size({storage}, dim={axis + 1}, kind=c_int64_t)"), + ) + for axis in range(transfer.rank) + ) + if transfer.abi is CallbackABIKind.DATA_AND_LENGTH: + return ( + FortranAssignment( + f"{base}_length", + CodeExpression(f"int(len({storage}), kind=c_int64_t)"), + ), + ) + return () + def _callback_result_declarations( self, callback: CallbackHandoffPlan, @@ -1134,6 +1233,8 @@ def _callback_native_result_type(self, transfer: CallbackTransferPlan | None) -> def _callback_native_type(self, transfer: CallbackTransferPlan) -> str: """Return one typed native callback value without selecting behavior.""" + if transfer.native_fortran_type is not None: + return transfer.native_fortran_type if transfer.abi is CallbackABIKind.DERIVED_ADDRESS: if transfer.derived_backend_symbol is None: raise ValueError(f"Callback derived transfer {transfer.owner_path!r} has no backend symbol") @@ -1142,6 +1243,12 @@ def _callback_native_type(self, transfer: CallbackTransferPlan) -> str: return f"character(kind=c_char, len={transfer.character_length})" return PrimitiveScalarTypeRegistry.type_for(transfer.semantic_type_name).fortran_spelling + def _callback_abi_storage_type(self, transfer: CallbackTransferPlan) -> str: + """Return the interoperable storage type selected for the C trampoline.""" + if transfer.native_fortran_type is not None: + return PrimitiveScalarTypeRegistry.type_for(transfer.semantic_type_name).fortran_spelling + return self._callback_native_type(transfer) + @staticmethod def _callback_parameter_base_name(transfer: CallbackTransferPlan) -> str: """Return the base Fortran dummy name reserved for one callback transfer.""" @@ -3726,7 +3833,15 @@ def _visit_ArgumentTransferPlan(self, plan: ArgumentTransferPlan) -> tuple[Fortr def _lower_argument(self, plan: ArgumentTransferPlan) -> tuple[FortranParameter, ...]: """Dispatch one completed bridge optional mode explicitly.""" if plan.callback is not None: - return () + if not plan.entrypoint.pass_callback_parameter: + return () + return ( + FortranParameter( + plan.entrypoint.parameter_name, + "type(c_funptr)", + ("value",), + ), + ) mode = plan.entrypoint.optional_mode if plan.object_kind is ObjectKind.DERIVED_TYPE: return self._lower_derived_argument(plan, mode) @@ -4672,6 +4787,8 @@ def _native_argument_expression(self, plan: ArgumentTransferPlan) -> str: def _presence_condition(self, plan: ArgumentTransferPlan) -> str: """Return the local C-pointer association condition for one nullable entrypoint argument.""" name = plan.entrypoint.parameter_name + if plan.callback is not None: + return f"c_associated({name})" if plan.derived_call is not None: return f"bound_{name}_access /= 0_c_int" handle = plan.native_array_handle @@ -4695,6 +4812,8 @@ def _present_preparation( return () action = plan.bridge.data_action match action: + case BridgeDataAction.DIRECT_TRANSFER: + return () case BridgeDataAction.ASSOCIATE_VIEW: return self._prepare_present_associated_view(plan) case BridgeDataAction.COPY_REPRESENTATION: @@ -4810,6 +4929,8 @@ def _optional_argument_declarations( argument: ArgumentTransferPlan, ) -> tuple[FortranDeclaration, ...]: """Return optional helper declarations for one completed handoff.""" + if argument.callback is not None: + return () handle = argument.native_array_handle if handle is not None and handle.handoff.abi is NativeDescriptorHandoffABI.FORTRAN_OWNER: if handle.owner_type_name is None: @@ -8401,6 +8522,8 @@ def _procedure_prototype_parameter( attributes.append("value") if argument.intent is not None: attributes.append(f"intent({argument.intent})") + if argument.optional: + attributes.append("optional") if argument.rank: attributes.append(f"dimension({self._procedure_prototype_shape(argument.array, argument.owner_path)})") return FortranParameter( @@ -8424,6 +8547,8 @@ def _procedure_prototype_type( value: ProcedurePrototypeArgumentPlan | ProcedurePrototypeResultPlan, ) -> str: """Return the native type shared by callback and direct prototype uses.""" + if isinstance(value, ProcedurePrototypeArgumentPlan) and value.native_fortran_type is not None: + return value.native_fortran_type if value.derived_backend_symbol is not None: return f"type({self._derived_native_alias(value.derived_backend_symbol)})" if value.semantic_type_name == "String": @@ -9395,6 +9520,11 @@ def _uses_c_function_pointer_symbols(self, plan: ModulePlan) -> bool: for operation in plan.entrypoint.support_procedures for parameter in operation.signature.parameters ) + callback_parameters = any( + argument.entrypoint.pass_callback_parameter + for function in self._functions(plan) + for argument in function.arguments + ) module_descriptors = any(self._uses_module_descriptor_backend(variable) for variable in self._variables(plan)) field_descriptors = any( field.access @@ -9405,7 +9535,7 @@ def _uses_c_function_pointer_symbols(self, plan: ModulePlan) -> bool: for derived in self._derived_types(plan) for field in derived.fields ) - return support_callbacks or module_descriptors or field_descriptors + return support_callbacks or callback_parameters or module_descriptors or field_descriptors def _uses_derived_interop_symbols(self, plan: ModulePlan) -> bool: """Return whether completed derived call or module-variable actions require derived interop support.""" diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index a60e2d093..ddfdfa768 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -2677,7 +2677,10 @@ def _argument_policy_consistency_diagnostics( adapter = slot.adapter if adapter is None: return (self._diagnostic(plan.owner_path, "missing-argument-adapter-facet", None),) - if plan.entrypoint.pass_callback_parameter: + expected_callback_parameter = bool( + plan.callback is not None and plan.entrypoint.optional_mode is OptionalMode.NULLABLE_VALUE + ) + if plan.entrypoint.pass_callback_parameter is not expected_callback_parameter: diagnostics.append( self._diagnostic( plan.owner_path, diff --git a/prik/planning/models.py b/prik/planning/models.py index 355e4bc2c..b3fceeda5 100644 --- a/prik/planning/models.py +++ b/prik/planning/models.py @@ -41,6 +41,7 @@ CallbackFatalAction, CallbackGILAction, CallbackLifecycleAction, + CallbackOptionalityAction, CallbackResultAction, CallbackThreadAction, CallbackTransferAction, @@ -1134,9 +1135,11 @@ class ProcedurePrototypeArgumentPlan(StageRecord): owner_path: str name: str semantic_type_name: str + native_fortran_type: str | None rank: int passed_by_value: bool intent: str | None + optional: bool character_length: int | None array: ArrayHandoffPlan | None derived_type_identity: tuple[str, str] | None @@ -1184,10 +1187,12 @@ class CallbackTransferPlan(StageRecord): owner_path: str name: str semantic_type_name: str + native_fortran_type: str | None object_kind: ObjectKind rank: int passed_by_value: bool intent: str | None + optionality: CallbackOptionalityAction abi: CallbackABIKind adapter_action: CallbackTransferAction python_action: PythonBarrierAction diff --git a/prik/planning/planner.py b/prik/planning/planner.py index c9d56b6b5..dfb6bc8df 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -1977,10 +1977,12 @@ def _callback_transfer_plan(self, policy: CallbackTransferPolicy) -> CallbackTra owner_path=policy.owner_path, name=policy.name, semantic_type_name=policy.semantic_type_name, + native_fortran_type=policy.native_fortran_type, object_kind=policy.object_kind, rank=policy.rank, passed_by_value=policy.passed_by_value, intent=policy.intent, + optionality=policy.optionality, abi=policy.abi, adapter_action=policy.adapter_action, python_action=policy.python_action, @@ -2025,9 +2027,11 @@ def _procedure_prototype_argument_plan( owner_path=policy.owner_path, name=policy.name, semantic_type_name=policy.semantic_type_name, + native_fortran_type=policy.native_fortran_type, rank=policy.rank, passed_by_value=policy.passed_by_value, intent=policy.intent, + optional=policy.optional, character_length=policy.character_length, array=self._array_plan(policy.array, policy.owner_path), derived_type_identity=policy.derived_type_identity, diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 79787efec..2ff497a01 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -85,6 +85,7 @@ TransformationLayer, TransformationAction, CallbackABIKind, + CallbackOptionalityAction, CallbackTransferAction, CallbackResultAction, CallbackLifecycleAction, @@ -1505,10 +1506,12 @@ def _callback_transfer_policy( passed_by_value = bool(argument.origin.metadata.get("value")) derived = _is_scalar_derived_type(semantic_type) array = _array_handoff_policy(semantic_type) if int(semantic_type.rank or 0) > 0 else None + _logical_abi, native_fortran_type = _scalar_logical_argument_abi(argument) return CallbackTransferPolicy( owner_path=owner_path, name=argument.name, semantic_type_name=semantic_type.name, + native_fortran_type=native_fortran_type, object_kind=decision.kind, rank=int(semantic_type.rank or 0), passed_by_value=passed_by_value, @@ -1517,6 +1520,13 @@ def _callback_transfer_policy( if (intent := argument.origin.metadata.get(models.PROTOTYPE_INTENT_METADATA)) is not None else None ), + optionality=( + CallbackOptionalityAction.BLOCKED + if argument.optional and passed_by_value + else CallbackOptionalityAction.NULL_DATA_POINTER + if argument.optional + else CallbackOptionalityAction.REQUIRED + ), abi=_callback_abi_kind(argument, derived=derived), adapter_action=_callback_adapter_action(argument), python_action=decision.python_barrier_action, @@ -1578,8 +1588,11 @@ def _callback_transfer_blockers( """Reject callback forms whose typed adapter ABI is incomplete.""" semantic_type = argument.semantic_type blockers = list(_runtime_semantic_validation_blockers(semantic_type, f"callback argument {argument.name!r}")) - if argument.optional: - blockers.append(f"callback argument {argument.name!r} cannot be optional") + if transfer.optionality is CallbackOptionalityAction.BLOCKED: + blockers.append( + f"callback argument {argument.name!r} cannot be both optional and passed by value; " + "use a reference dummy so absence has a null-pointer ABI" + ) if _uses_unsupported_callback_descriptor(semantic_type): blockers.append( f"callback argument {argument.name!r} uses unsupported allocatable, pointer, " @@ -1634,10 +1647,12 @@ def _callback_result_policy( owner_path=owner_path, name="result", semantic_type_name=return_type.name, + native_fortran_type=None, object_kind=decision.kind, rank=int(return_type.rank or 0), passed_by_value=False, intent=None, + optionality=CallbackOptionalityAction.REQUIRED, abi=( CallbackABIKind.DERIVED_ADDRESS if derived @@ -2025,7 +2040,8 @@ def _complete_entrypoint_argument_route( entrypoint_pass_descriptor_presence=(uses_adapter and argument.optional_mode is OptionalMode.DESCRIPTOR), entrypoint_pass_derived_transaction=(uses_adapter and argument.derived_call is not None), entrypoint_pass_callback_parameter=( - action is NativeEntrypointAction.DIRECT_C_ABI and argument.callback is not None + argument.callback is not None + and (action is NativeEntrypointAction.DIRECT_C_ABI or argument.optional_mode is OptionalMode.NULLABLE_VALUE) ), entrypoint_optionality=( EntrypointOptionalityAction.EXPLICIT_NATIVE_PRESENCE @@ -3270,11 +3286,12 @@ def _argument_boundary_policy( ) -> _ArgumentBoundaryPolicy: """Normalize callback inputs onto the ordinary argument-policy schema.""" if callback is not None: + optional_mode = OptionalMode.NULLABLE_VALUE if argument.optional else OptionalMode.REQUIRED return _ArgumentBoundaryPolicy( - optional_mode=OptionalMode.REQUIRED, + optional_mode=optional_mode, conversion_phase=ArgumentConversionPhase.IMMEDIATE, handoff_mode=ArgumentHandoffMode.VALUE, - nullable=False, + nullable=argument.optional, writable=False, descriptor_boundary=False, codegen_action=CodegenAction.CALL_LOCAL_INPUT, @@ -3334,8 +3351,6 @@ def _completed_argument_blockers( if callback is not None: blockers.extend(callback.blockers) blockers.extend(_callback_derived_type_blockers(callback, derived_types)) - if argument.optional: - blockers.append(f"argument {argument.name!r} is an unsupported optional callback") else: blockers.extend( _argument_blockers( @@ -7296,7 +7311,12 @@ def _scalar_logical_argument_abi( if source_type is None: if semantic_type.name in {"Bool", "Bool8"}: return ScalarLogicalABI.C_BOOL, "logical(c_bool)" - return ScalarLogicalABI.NATIVE_KIND_COPY, None + native_kind = {"Bool16": 2, "Bool32": 4, "Bool64": 8}.get(semantic_type.name) + return ( + (ScalarLogicalABI.NATIVE_KIND_COPY, f"logical(kind={native_kind})") + if native_kind is not None + else (ScalarLogicalABI.NATIVE_KIND_COPY, None) + ) compact = "".join(source_type.casefold().split()) if compact == "logical(kind=c_bool)": return ScalarLogicalABI.C_BOOL, "logical(c_bool)" @@ -7966,6 +7986,7 @@ def _semantic_prototype_argument_policy( owner_path=f"{owner_path}.prototype_argument.{argument.name}", name=argument.name, semantic_type_name=semantic_type.name, + native_fortran_type=_scalar_logical_argument_abi(argument)[1], rank=int(semantic_type.rank or 0), passed_by_value=bool(argument.origin.metadata.get("value")), intent=( @@ -7973,6 +7994,7 @@ def _semantic_prototype_argument_policy( if (intent := argument.origin.metadata.get(models.PROTOTYPE_INTENT_METADATA)) is not None else None ), + optional=argument.optional, character_length=_character_length(semantic_type), array=_array_handoff_policy(semantic_type) if int(semantic_type.rank or 0) > 0 else None, derived_type_identity=( diff --git a/prik/policy/models.py b/prik/policy/models.py index 4e0b938df..c9ce72bda 100644 --- a/prik/policy/models.py +++ b/prik/policy/models.py @@ -273,6 +273,14 @@ class CallbackTransferAction(str, Enum): BORROW_WRITABLE = "borrow_writable" +class CallbackOptionalityAction(str, Enum): + """Completed presence ABI for one callback dummy.""" + + REQUIRED = "required" + NULL_DATA_POINTER = "null_data_pointer" + BLOCKED = "blocked" + + class CallbackResultAction(str, Enum): """Typed result conversion performed by one callback trampoline.""" @@ -1045,9 +1053,11 @@ class ProcedurePrototypeArgumentPolicy: owner_path: str name: str semantic_type_name: str + native_fortran_type: str | None rank: int passed_by_value: bool intent: str | None + optional: bool character_length: int | None array: ArrayHandoffPolicy | None derived_type_identity: tuple[str, str] | None @@ -1235,10 +1245,12 @@ class CallbackTransferPolicy: owner_path: str name: str semantic_type_name: str + native_fortran_type: str | None object_kind: ObjectKind rank: int passed_by_value: bool intent: str | None + optionality: CallbackOptionalityAction abi: CallbackABIKind adapter_action: CallbackTransferAction python_action: PythonBarrierAction diff --git a/tests/fortran/callbacks/codegen/test_callback_planning.py b/tests/fortran/callbacks/codegen/test_callback_planning.py index 1077eb750..602f816bc 100644 --- a/tests/fortran/callbacks/codegen/test_callback_planning.py +++ b/tests/fortran/callbacks/codegen/test_callback_planning.py @@ -15,6 +15,7 @@ CallbackResultAction, CallbackThreadAction, CallbackTransferAction, + OptionalMode, ) from prik.pipeline.wrapper import WrapperGenerator from prik.planning import GeneratedSupportProcedureImplementationOwner, WrapperPlanner @@ -242,14 +243,24 @@ def test_every_callback_uses_the_shared_generated_abstract_prototype(): assert "=> transform_callback" not in bridge -def test_optional_callback_retains_one_exact_policy_blocker(): +def test_optional_callback_uses_the_ordinary_presence_plan(): module = pyi_file_to_semantic_module(CONTRACT, module_name="fcallback_all_f90") function = next(item for item in module.functions if item.name == "apply_value_callback") function.arguments[0].optional = True complete_semantic_policies(module) - with pytest.raises(ValueError, match="unsupported optional callback"): - WrapperPlanner().build(module) + plan = WrapperPlanner().build(module) + argument = _callback_argument(plan, "apply_value_callback") + + assert argument.binding.optional_mode is OptionalMode.NULLABLE_VALUE + assert argument.entrypoint.optional_mode is OptionalMode.NULLABLE_VALUE + assert argument.entrypoint.pass_callback_parameter is True + + c_source, bridge = _sources(plan) + assert "bound_callback_obj != Py_None ? prik_callback_trampoline_" in c_source + assert "if (c_associated(callback)) then" in bridge + assert "native_apply_value_callback(callback=prik_callback_adapter_" in bridge + assert "native_apply_value_callback(value=value)" in bridge def test_runtime_callback_extents_lower_to_assumed_shape_dummies_and_measured_copies(): diff --git a/tests/fortran/callbacks/end_to_end/test_optional_callbacks.py b/tests/fortran/callbacks/end_to_end/test_optional_callbacks.py new file mode 100644 index 000000000..df272c82c --- /dev/null +++ b/tests/fortran/callbacks/end_to_end/test_optional_callbacks.py @@ -0,0 +1,176 @@ +"""Optional callback presence across source and generated-contract builds.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +from tests.fortran._support.wrapper_build import _build_source_or_generated_pyi_and_import + + +pytestmark = pytest.mark.fortran_end_to_end + + +SOURCE = """ +module fcallback_optional_f90 + use iso_c_binding + implicit none + + abstract interface + subroutine report(value, status, values, terminate) + integer, intent(in) :: value + integer, intent(in), optional :: status + real(8), intent(in), optional :: values(:) + logical, intent(out), optional :: terminate + end subroutine report + + subroutine c_report(value, status) bind(C) + import c_int + integer(c_int), value, intent(in) :: value + integer(c_int), intent(in), optional :: status + end subroutine c_report + end interface + +contains + + integer function run(mode, callback) result(output) + integer, intent(in) :: mode + procedure(report), optional :: callback + integer :: status + real(8) :: values(2) + logical :: terminate + + output = -1 + if (.not. present(callback)) return + output = mode + select case (mode) + case (0) + call callback(4) + case (1) + status = 9 + call callback(4, status) + case (2) + status = 9 + values = [1.5d0, 2.5d0] + call callback(4, status, values) + case (3) + terminate = .false. + call callback(4, terminate=terminate) + if (terminate) output = 99 + end select + end function run + + integer(c_int) function direct_run(mode, callback) bind(C) result(output) + integer(c_int), value, intent(in) :: mode + procedure(c_report), optional :: callback + integer(c_int) :: status + + output = -1_c_int + if (.not. present(callback)) return + output = mode + if (mode == 0_c_int) then + call callback(4_c_int) + else + status = 9_c_int + call callback(4_c_int, status) + end if + end function direct_run +end module fcallback_optional_f90 +""" + + +@pytest.fixture +def optional_callback_module(pyi_parity_build_mode: str, tmp_path: Path): + source = tmp_path / "fcallback_optional_f90.f90" + source.write_text(SOURCE, encoding="utf-8") + module = _build_source_or_generated_pyi_and_import( + source, + tmp_path, + { + "bind_c_fcallback_optional_f90_wrapper.f90", + "fcallback_optional_f90_wrapper.c", + "fcallback_optional_f90_wrapper.h", + }, + None, + pyi_parity_build_mode, + ) + build_dir = ( + tmp_path / "source_build" + if pyi_parity_build_mode == "source" + else tmp_path / "generated_pyi_build" / "pyi_build" + ) + return module, build_dir, pyi_parity_build_mode + + +def test_optional_callback_and_optional_dummies_preserve_each_presence_state(optional_callback_module): + module, _build_dir, _build_mode = optional_callback_module + + assert module.run(np.int32(0)) == np.int32(-1) + assert module.run(np.int32(0), None) == np.int32(-1) + + observed = [] + + def report(value, status, values, terminate): + observed.append((value, status, None if values is None else np.array(values), terminate)) + if terminate is not None: + terminate[...] = True + + assert module.run(np.int32(0), report) == np.int32(0) + assert observed[-1] == (np.int32(4), None, None, None) + + assert module.run(np.int32(1), report) == np.int32(1) + assert observed[-1] == (np.int32(4), np.int32(9), None, None) + + assert module.run(np.int32(2), report) == np.int32(2) + value, status, values, terminate = observed[-1] + assert (value, status, terminate) == (np.int32(4), np.int32(9), None) + np.testing.assert_array_equal(values, np.array([1.5, 2.5], dtype=np.float64)) + + assert module.run(np.int32(3), report) == np.int32(99) + assert observed[-1][1:3] == (None, None) + assert isinstance(observed[-1][3], np.ndarray) + assert observed[-1][3].shape == () + + +def test_optional_bind_c_callback_remains_direct_and_preserves_inner_presence(optional_callback_module): + module, build_dir, build_mode = optional_callback_module + + assert module.direct_run(np.int32(0)) == np.int32(-1) + assert module.direct_run(np.int32(0), None) == np.int32(-1) + seen = [] + assert module.direct_run(np.int32(0), lambda value, status: seen.append((value, status))) == np.int32(0) + assert module.direct_run(np.int32(1), lambda value, status: seen.append((value, status))) == np.int32(1) + assert seen == [(np.int32(4), None), (np.int32(4), np.int32(9))] + + if build_mode == "source": + binding = (build_dir / "fcallback_optional_f90_wrapper.c").read_text(encoding="utf-8") + bridge = (build_dir / "bind_c_fcallback_optional_f90_wrapper.f90").read_text(encoding="utf-8") + assert "direct_run(int32_t mode, void (*callback)(int32_t, void *));" in binding + assert "direct_run(bound_mode, bound_callback_obj != Py_None ? prik_callback_trampoline_" in binding + assert "function bind_c_direct_run" not in bridge.casefold() + + +def test_exception_propagation_is_unchanged_for_a_supplied_optional_callback(optional_callback_module): + _module, build_dir, _build_mode = optional_callback_module + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import numpy as np; import fcallback_optional_f90 as root; " + "module = root.fcallback_optional_f90; " + "module.run(np.int32(0), lambda *args: (_ for _ in ()).throw(ValueError('optional exploded')))" + ), + ], + cwd=build_dir, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode != 0 + assert "ValueError: optional exploded" in result.stderr diff --git a/tests/fortran/callbacks/policy/test_callback_policy.py b/tests/fortran/callbacks/policy/test_callback_policy.py index 3689016b0..d0b874c65 100644 --- a/tests/fortran/callbacks/policy/test_callback_policy.py +++ b/tests/fortran/callbacks/policy/test_callback_policy.py @@ -14,6 +14,7 @@ from prik.policy.ownership import PythonBarrierAction from prik.policy.models import ( CallbackABIKind, + CallbackOptionalityAction, CallbackTransferAction, FunctionWrapperPolicy, ) @@ -59,7 +60,8 @@ def test_source_callback_value_default_and_explicit_reference_are_completed(): ), ( "def callback_shape(value: Float64 = ...) -> None: ...", - "callback argument 'value' cannot be optional", + "callback argument 'value' cannot be both optional and passed by value; " + "use a reference dummy so absence has a null-pointer ABI", ), ( "def callback_shape() -> Pointer[Float64]: ...", @@ -67,7 +69,7 @@ def test_source_callback_value_default_and_explicit_reference_are_completed(): ), ], ) -def test_callback_descriptor_and_optional_forms_are_blocked_before_codegen(prototype: str, blocker: str): +def test_unsupported_callback_forms_are_blocked_before_codegen(prototype: str, blocker: str): module = parse_pyi_text( f""" @prototype @@ -214,6 +216,26 @@ def apply(callback: callback_shape) -> None: ... assert policy.arguments[0].callback.arguments[0].python_action is PythonBarrierAction.SCALAR_VALUE +def test_optional_reference_callback_dummy_has_one_null_pointer_presence_decision(): + module = parse_pyi_text( + """ +@prototype +def callback_shape(value: In(Addr(Int32)) = ...) -> None: ... + +def apply(callback: callback_shape = ...) -> None: ... +""", + module_name="optional_callback", + ) + + complete_semantic_policies(module) + policy = completed_function_wrapper_policy(module.functions[0]) + + argument = policy.arguments[0] + assert argument.optional is True + assert argument.callback.arguments[0].optionality is CallbackOptionalityAction.NULL_DATA_POINTER + assert argument.callback.prototype.arguments[0].optional is True + + 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. diff --git a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py index 78576312f..8fee216c0 100644 --- a/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py +++ b/tests/fortran/callbacks/semantics/test_fortran_callback_semantics.py @@ -186,6 +186,38 @@ def test_dummy_procedure_interfaces_become_complete_callable_contracts(): assert standalone_callback.metadata["return"].name == "Int32" +def test_optional_callback_presence_round_trips_through_one_contract_spelling(): + source = """ +module optional_callbacks + abstract interface + subroutine report(value, status) + integer, intent(in) :: value + integer, intent(in), optional :: status + end subroutine report + end interface +contains + subroutine run(callback) + procedure(report), optional :: callback + end subroutine run +end module optional_callbacks +""" + module = FortranToIRConverter().visit(parse_fortran_source(source).modules[0]) + complete_python_export_policy(module) + + callback = get_function(module, "run").arguments[0] + assert callback.optional is True + assert callback.semantic_type.metadata["callback_arguments"][1].optional is True + + contract = emit_module(module) + assert "status: In(Addr(Int32)) = ..." in contract + assert "callback: report = ..." in contract + + reloaded = parse_pyi_text(contract, module_name="optional_callbacks") + reloaded_callback = get_function(reloaded, "run").arguments[0] + assert reloaded_callback.optional is True + assert reloaded_callback.semantic_type.metadata["callback_arguments"][1].optional is True + + def test_duplicate_interface_signatures_emit_one_named_callback_prototype(): source = """ module duplicate_prototypes diff --git a/tests/fortran/optional_arguments/policy/test_optional_policy.py b/tests/fortran/optional_arguments/policy/test_optional_policy.py index 21b1a08d9..41839ca55 100644 --- a/tests/fortran/optional_arguments/policy/test_optional_policy.py +++ b/tests/fortran/optional_arguments/policy/test_optional_policy.py @@ -115,7 +115,7 @@ def alloc_state(value: Float64 | None) -> Int32: ... assert value.bridge_data_action is BridgeDataAction.COPY_REPRESENTATION -def test_optional_passed_procedure_is_blocked_before_codegen(): +def test_optional_value_callback_dummy_is_blocked_before_codegen(): module = parse_pyi_text( """ @prototype @@ -131,4 +131,7 @@ def apply(callback: callback_shape) -> None: ... policy = module.functions[0].metadata[RESOLVED_FUNCTION_WRAPPER_POLICY_METADATA] assert isinstance(policy, FunctionWrapperPolicy) assert policy.supported is False - assert "callback argument 'value' cannot be optional" in policy.blockers + assert ( + "callback argument 'value' cannot be both optional and passed by value; " + "use a reference dummy so absence has a null-pointer ABI" + ) in policy.blockers From fdf4f2b92b0849095a0fe9cdd9ec06ab5bbf66ac Mon Sep 17 00:00:00 2001 From: said Date: Sun, 20 Sep 2026 12:29:50 +0100 Subject: [PATCH 95/96] codex: validate callback presence plans --- prik/pipeline/wrapper.py | 26 +++++++++++++++++++ .../codegen/test_callback_planning.py | 17 ++++++++++++ 2 files changed, 43 insertions(+) diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index ddfdfa768..90cb50b37 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -44,6 +44,7 @@ CallbackFatalAction, CallbackGILAction, CallbackLifecycleAction, + CallbackOptionalityAction, CallbackResultAction, CallbackThreadAction, CallbackTransferAction, @@ -2234,9 +2235,11 @@ def _prototype_argument_matches_transfer( return ( argument.name, argument.semantic_type_name, + argument.native_fortran_type, argument.rank, argument.passed_by_value, argument.intent, + argument.optional, argument.character_length, WrapperGenerator._prototype_array_shape(argument.array), argument.derived_type_identity, @@ -2244,9 +2247,11 @@ def _prototype_argument_matches_transfer( ) == ( transfer.name, transfer.semantic_type_name, + transfer.native_fortran_type, transfer.rank, transfer.passed_by_value, transfer.intent, + transfer.optionality is CallbackOptionalityAction.NULL_DATA_POINTER, transfer.character_length, WrapperGenerator._prototype_array_shape(transfer.array), transfer.derived_type_identity, @@ -2348,12 +2353,33 @@ def _callback_transfer_diagnostics( diagnostics = [] if not transfer.owner_path or not transfer.name: diagnostics.append(self._diagnostic(transfer.owner_path, "incomplete-callback-transfer", position)) + diagnostics.extend(self._callback_optionality_diagnostics(transfer, position)) diagnostics.extend(self._callback_array_role_diagnostics(transfer, position)) diagnostics.extend(self._callback_string_role_diagnostics(transfer, position)) diagnostics.extend(self._callback_derived_role_diagnostics(transfer, position)) diagnostics.extend(self._callback_scalar_projection_diagnostics(transfer, position)) return tuple(diagnostics) + def _callback_optionality_diagnostics( + self, + transfer: CallbackTransferPlan, + position: int, + ) -> tuple[WrapperPlanDiagnostic, ...]: + """Reject a presence action that cannot use the transfer's completed ABI.""" + invalid = transfer.optionality is CallbackOptionalityAction.BLOCKED or ( + transfer.optionality is CallbackOptionalityAction.NULL_DATA_POINTER + and (transfer.abi is CallbackABIKind.VALUE or transfer.passed_by_value) + ) + if not invalid: + return () + return ( + self._diagnostic( + transfer.owner_path, + "invalid-callback-optionality", + (position, transfer.optionality.value, transfer.abi.value), + ), + ) + def _callback_scalar_projection_diagnostics( self, transfer: CallbackTransferPlan, diff --git a/tests/fortran/callbacks/codegen/test_callback_planning.py b/tests/fortran/callbacks/codegen/test_callback_planning.py index 602f816bc..ca5fac7e7 100644 --- a/tests/fortran/callbacks/codegen/test_callback_planning.py +++ b/tests/fortran/callbacks/codegen/test_callback_planning.py @@ -12,6 +12,7 @@ CallbackABIKind, CallbackGILAction, CallbackLifecycleAction, + CallbackOptionalityAction, CallbackResultAction, CallbackThreadAction, CallbackTransferAction, @@ -148,6 +149,10 @@ def test_callback_plan_projects_one_explicit_site_and_stable_roles_per_argument( ("scalar_projection", "inconsistent-callback-scalar-value-projection"), ("result", "callback-void-has-transfer"), ("entrypoint_parameter", "inconsistent-callback-entrypoint-parameter"), + ("prototype_optional", "inconsistent-callback-prototype-arguments"), + ("native_fortran_type", "inconsistent-callback-prototype-arguments"), + ("optional_value_abi", "invalid-callback-optionality"), + ("blocked_optionality", "invalid-callback-optionality"), ("symbols", "invalid-callback-symbols"), ), ) @@ -170,6 +175,18 @@ def test_callback_plan_edits_fail_central_validation_before_backend_emission(edi elif edit == "entrypoint_parameter": argument = _callback_argument(plan, "apply_value_callback") argument.entrypoint.pass_callback_parameter = True + elif edit == "prototype_optional": + callback = _callback_argument(plan, "apply_value_callback").callback + callback.prototype.arguments[0].optional = True + elif edit == "native_fortran_type": + callback = _callback_argument(plan, "apply_value_callback").callback + callback.prototype.arguments[0].native_fortran_type = "logical(kind=8)" + elif edit == "optional_value_abi": + callback = _callback_argument(plan, "apply_value_callback").callback + callback.arguments[0].optionality = CallbackOptionalityAction.NULL_DATA_POINTER + elif edit == "blocked_optionality": + callback = _callback_argument(plan, "apply_value_callback").callback + callback.arguments[0].optionality = CallbackOptionalityAction.BLOCKED else: callback = _callback_argument(plan, "apply_value_callback").callback callback.entrypoint.support_procedure.symbol_name = callback.bridge.adapter_symbol From 96f78f818b83be80bccce828edcaf1d7b73bc489 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 20 Sep 2026 12:32:18 +0100 Subject: [PATCH 96/96] codex: omit direct callback bridge adapters --- prik/codegen/fortran/bridge.py | 12 ++++--- .../codegen/test_callback_planning.py | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index 24c8b94ac..e1035b9ba 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -53,6 +53,7 @@ NativeArrayOwnerStorage, NativeArrayResultAllocation, NativeDescriptorHandoffABI, + NativeEntrypointAction, NativeInvocationKind, EntrypointPassingConvention, EntrypointProjectionAction, @@ -518,7 +519,8 @@ def _support_procedure_fortran_type(value: NativeEntrypointABIValuePlan) -> str def _callback_standalone_adapter_procedures(self, plan: ModulePlan) -> tuple[FortranFunction, ...]: """Return separately linked callback adapters in stable site order.""" return tuple( - self._callback_standalone_adapter_procedure(callback, plan) for callback in self._callback_sites(plan) + self._callback_standalone_adapter_procedure(callback, plan) + for callback in self._callback_adapter_sites(plan) ) def _derived_holder_definitions(self, plan: ModulePlan) -> tuple[FortranTypeDefinition, ...]: @@ -8472,7 +8474,7 @@ def _prototype_interfaces( def _prototype_plans(self, plan: ModulePlan) -> tuple[ProcedurePrototypePlan, ...]: """Deduplicate callback and direct-call uses by generated interface symbol.""" candidates = ( - *(callback.prototype for callback in self._callback_sites(plan)), + *(callback.prototype for callback in self._callback_adapter_sites(plan)), *( declaration.prototype for function in self._functions(plan) @@ -8662,11 +8664,12 @@ def _native_external_declarations(self, plan: FunctionPlan) -> tuple[FortranDecl ), ) - def _callback_sites(self, plan: ModulePlan) -> tuple[CallbackHandoffPlan, ...]: - """Return callback sites in stable native-call order.""" + def _callback_adapter_sites(self, plan: ModulePlan) -> tuple[CallbackHandoffPlan, ...]: + """Return callback sites whose completed route needs a Fortran adapter.""" return tuple( argument.callback for function in self._functions(plan) + if function.entrypoint.action is NativeEntrypointAction.GENERATED_FORTRAN_ADAPTER for argument in sorted(function.arguments, key=lambda item: item.native_position) if argument.callback is not None ) @@ -9523,6 +9526,7 @@ def _uses_c_function_pointer_symbols(self, plan: ModulePlan) -> bool: callback_parameters = any( argument.entrypoint.pass_callback_parameter for function in self._functions(plan) + if function.entrypoint.action is NativeEntrypointAction.GENERATED_FORTRAN_ADAPTER for argument in function.arguments ) module_descriptors = any(self._uses_module_descriptor_backend(variable) for variable in self._variables(plan)) diff --git a/tests/fortran/callbacks/codegen/test_callback_planning.py b/tests/fortran/callbacks/codegen/test_callback_planning.py index ca5fac7e7..58d59255d 100644 --- a/tests/fortran/callbacks/codegen/test_callback_planning.py +++ b/tests/fortran/callbacks/codegen/test_callback_planning.py @@ -4,7 +4,9 @@ import pytest +from prik.parsers.fortran import parse_fortran_file as parse_fortran_source from prik.pipeline.pyi import pyi_file_to_semantic_module, pyi_text_to_semantic_module +from prik.semantics.fortran2ir import FortranToIRConverter from prik.semantics import models from prik.policy.ownership import PythonBarrierAction from prik.policy.completion import complete_semantic_policies @@ -280,6 +282,38 @@ def test_optional_callback_uses_the_ordinary_presence_plan(): assert "native_apply_value_callback(value=value)" in bridge +def test_direct_bind_c_callback_generates_no_fortran_callback_adapter(): + source = """ +module direct_callback + use iso_c_binding + implicit none + + abstract interface + subroutine report(value) bind(C) + import c_int + integer(c_int), value, intent(in) :: value + end subroutine report + end interface + +contains + + subroutine run(callback) bind(C) + procedure(report) :: callback + call callback(4_c_int) + end subroutine run +end module direct_callback +""" + module = FortranToIRConverter().visit(parse_fortran_source(source).modules[0]) + complete_semantic_policies(module) + plan = WrapperPlanner().build(module) + callback = _callback_argument(plan, "run").callback + + artifacts = WrapperGenerator().generate(plan) + c_source = next(source.text for source in artifacts.sources if source.path.suffix == ".c") + assert callback.entrypoint.support_procedure.symbol_name in c_source + assert all(source.path.suffix != ".f90" for source in artifacts.sources) + + 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.