From 3d543d067deaf6ee25d54fc82e171b13f1818932 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 27 Sep 2026 13:13:15 +0100 Subject: [PATCH 01/11] codex: lend scalar allocatable and pointer fields as live views Scalar allocatable and pointer fields of a derived type, numeric or character, now read as live read-only rank-zero views or None and are assigned through an allocating or target copy, the way the matching module variables are. They previously failed to build. Pointer holders now report fields they cannot reach as a policy diagnostic instead of failing during planning, and the generated .pyi constructor no longer lists these fields. The README's Fortran limitations drop assumed-rank type(*) buffers and the now-supported fields, and end every item with a full stop. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 + README.md | 16 +- docs/user/guide/allocatables.md | 9 +- docs/user/guide/pointers.md | 8 +- docs/user/guide/wrapping-derived-types.md | 10 +- docs/user/language-support/feature-matrix.md | 4 +- prik/codegen/c/binding.py | 324 ++++++++++++------ prik/codegen/docstrings.py | 3 + prik/codegen/fortran/bridge.py | 285 +++++++++------ prik/pipeline/wrapper.py | 12 +- prik/planning/entrypoints.py | 39 ++- prik/planning/planner.py | 38 ++ prik/policy/construction.py | 97 +++--- prik/policy/models.py | 8 +- prik/policy/ownership.py | 31 +- prik/printers/pyi.py | 8 +- .../fderived_scalar_descriptor_fields.f90 | 34 ++ .../test_derived_runtime_mechanisms.py | 67 ++++ .../policy/test_derived_accessor_policy.py | 24 ++ .../policy/test_policy_completion.py | 35 +- 20 files changed, 748 insertions(+), 315 deletions(-) create mode 100644 tests/fortran/derived_types/end_to_end/fixtures/native/fderived_scalar_descriptor_fields.f90 diff --git a/CHANGELOG.md b/CHANGELOG.md index cadc008f8..3e64112f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ release tags add a leading `v` to the package version. ## Unreleased +- Scalar `allocatable` and `pointer` fields of a derived type, numeric, + logical, complex, or character, are now wrapped like the matching module + variables: reading one returns a live read-only rank-zero NumPy view or + `None`, and assigning to it allocates an allocatable (resizing a + deferred-length character) or writes a pointer's current target. The view + keeps its parent object alive. These fields are not keywords of the default + constructor, in the built class and in the generated `.pyi` alike. Such a + type previously failed to build. +- A derived object passed through a `pointer` dummy now reports a field it + cannot reach as a policy diagnostic, as an `allocatable` dummy already did, + instead of failing during wrapper planning. - Generated extension modules serve their module variables through descriptors on the module type, so looking up a function or any other ordinary attribute costs what it costs on a plain module instead of first diff --git a/README.md b/README.md index 4e2592aec..8472a7c75 100644 --- a/README.md +++ b/README.md @@ -235,11 +235,11 @@ code generation with a diagnostic naming the boundary and the reason. **Types and arrays** -- arrays of derived types and higher-rank assumed-size `type(*)` arrays; -- parameterized derived types such as `type :: buffer_type(k, n)`; -- character arrays that cannot be represented as a fixed-width NumPy bytes - dtype, and `allocatable` and `pointer` character *fields*. -- real and complex storage wider than the target's `long double`. NumPy's +- Arrays of derived types. +- Parameterized derived types such as `type :: buffer_type(k, n)`. +- Character arrays that cannot be represented as a fixed-width NumPy bytes + dtype. +- Real and complex storage wider than the target's `long double`. NumPy's `longdouble` is whatever the target C compiler provides, so `real(10)` and C `long double` are supported while IEEE quad `real(16)` is refused on a target whose `long double` is x87 extended precision. The diagnostic names the @@ -247,9 +247,9 @@ code generation with a diagnostic naming the boundary and the reason. **Procedures and polymorphism** -- procedure-pointer module variables, and - callbacks retained after the wrapped call returns; -- polymorphic outputs, mutable polymorphic arguments, polymorphic +- Procedure-pointer module variables, and + callbacks retained after the wrapped call returns. +- Polymorphic outputs, mutable polymorphic arguments, polymorphic `allocatable` and `pointer` scalars, and unlimited polymorphism (`class(*)`). The [language feature matrix](https://pynumlab.github.io/prik/user/language-support/feature-matrix/) diff --git a/docs/user/guide/allocatables.md b/docs/user/guide/allocatables.md index a75331be3..26f1b2612 100644 --- a/docs/user/guide/allocatables.md +++ b/docs/user/guide/allocatables.md @@ -17,8 +17,9 @@ and an array handle gives Python access to that descriptor. ## Key Concepts - Scalar allocatable dummies and results appear as values or `None`. Reading a - scalar allocatable module variable returns a live read-only rank-zero NumPy - view or `None`; assigning to it allocates when needed. Array allocatables use + scalar allocatable module variable or derived-type field returns a live + read-only rank-zero NumPy view or `None`; assigning to it allocates when + needed. Array allocatables use `Allocatable[T[...]]` handles. - An array handle exposes allocation state and descriptor operations; it is not itself a NumPy array. @@ -84,8 +85,8 @@ assert values.allocated is True The annotation supplies the element dtype and rank. The handle creates its native storage when first passed to a matching writable argument. It stays the same Python object after the call. -`Allocatable[Float64]()` is not supported. Reading a scalar module variable -declared `Allocatable[Float64]` returns a live read-only rank-zero `float64` +`Allocatable[Float64]()` is not supported. Reading a scalar module variable or +derived-type field declared `Allocatable[Float64]` returns a live read-only rank-zero `float64` array when allocated, or `None` otherwise. Assign to the attribute to change the value: `module.scale = np.float64(2.0)` allocates the variable when it is unallocated, and a deferred-length character takes the width of the assigned diff --git a/docs/user/guide/pointers.md b/docs/user/guide/pointers.md index 58fe297bf..3cefe2bcf 100644 --- a/docs/user/guide/pointers.md +++ b/docs/user/guide/pointers.md @@ -19,8 +19,8 @@ shape, and strides. It does not by itself say who owns that target. - A pointer descriptor refers to target storage; it does not own that storage by default. - Scalar pointer dummies and results appear as values or `None`. Reading a - scalar pointer module variable returns a live read-only rank-zero NumPy view - or `None`; assigning to it writes the current target. Array pointers use live + scalar pointer module variable or derived-type field returns a live read-only + rank-zero NumPy view or `None`; assigning to it writes the current target. Array pointers use live `Pointer[T[...]]` handles. - `associated` describes association, not ownership or target lifetime. - NumPy arrays returned by `to_numpy()` are live views, not copies. @@ -77,8 +77,8 @@ assert target.associated is True The annotation supplies the element dtype and rank. The handle creates its native storage when first passed to a matching writable argument. It stays the same Python object after the call. -`Pointer[Float64]()` is not supported. Reading a scalar module variable -declared `Pointer[Float64]` returns a live read-only rank-zero `float64` array +`Pointer[Float64]()` is not supported. Reading a scalar module variable or +derived-type field declared `Pointer[Float64]` returns a live read-only rank-zero `float64` array when associated, or `None` otherwise. Assigning to the attribute copies the value into the current target; it raises `ValueError` when the pointer is not associated, and a character value must encode to the target's width. Assignment diff --git a/docs/user/guide/wrapping-derived-types.md b/docs/user/guide/wrapping-derived-types.md index c0a0532f0..e698a9928 100644 --- a/docs/user/guide/wrapping-derived-types.md +++ b/docs/user/guide/wrapping-derived-types.md @@ -214,7 +214,13 @@ print(points.Point.__init__.__doc__) instance and do not return it again. - **Missing intent**: A dummy without `intent` follows the same conservative in-place rule as `intent(inout)`. -- **Fields**: Public scalar numeric/logical/complex fields become Python attributes. +- **Fields**: Public scalar numeric, logical, complex, and character fields + become Python attributes. A scalar `allocatable` or `pointer` field reads as + a live read-only rank-zero NumPy view, or `None` when it is unallocated or + disassociated. Assigning to it allocates an allocatable field or writes a + pointer field's current target, as for + [module variables](allocatables.md#key-concepts). The view keeps its parent + object alive; read the field again after its storage changes. - **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 @@ -223,7 +229,7 @@ print(points.Point.__init__.__doc__) 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. + primitive scalar fields that are not `allocatable` or `pointer`. - **Constructor fields**: Passed by keyword (`logical`, `integer`, `real`, and `complex`). diff --git a/docs/user/language-support/feature-matrix.md b/docs/user/language-support/feature-matrix.md index 70bb9dc11..9f55976cb 100644 --- a/docs/user/language-support/feature-matrix.md +++ b/docs/user/language-support/feature-matrix.md @@ -59,8 +59,8 @@ where they apply. | Defined operators and assignment overloads | Supported | [Defined operators](../guide/wrapping-derived-types.md#defined-operators) | [Defined operator tests](../../../tests/fortran/generic_interfaces/end_to_end/test_defined_operators.py) | Supported operators are those covered by the wrapper guide and runtime tests. | | Output arguments and multiple results | Supported | [Subroutine projection](../guide/wrapping-subroutines.md) | [Calls and results tests](../../../tests/fortran/infrastructure/semantic_pyi/contracts/calls_and_results/end_to_end/test_edited_call_surfaces.py), [function result tests](../../../tests/fortran/functions/end_to_end/test_documented_function_journeys.py) | Tuple ordering and caller-provided array behavior follow the wrapper guide. | | Optional arguments | Supported | [Optional arguments](../guide/optional-arguments.md) | [Optional argument tests](../../../tests/fortran/optional_arguments/end_to_end/test_optional_runtime.py) | Unsupported optional combinations fail during wrapper planning. | -| Allocatable array handles, descriptor arguments, and owned results | Supported | [Allocatables](../guide/allocatables.md) | [Allocatable runtime tests](../../../tests/fortran/allocatables/end_to_end/test_allocatable_handles.py), [scalar-derived matrix tests](../../../tests/fortran/derived_types/end_to_end/test_scalar_actual_dummy_matrix.py) | Module and field handles borrow their owner; returned and caller-created handles own their storage. Numeric and character arrays are supported. | -| Pointer scalar projections and array handles | Partially supported | [Pointers](../guide/pointers.md) | [Pointer handle tests](../../../tests/fortran/pointers/end_to_end/test_pointer_handles.py), [pointer policy tests](../../../tests/fortran/pointers/policy/test_pointer_ownership_policy.py), [scalar-derived matrix tests](../../../tests/fortran/derived_types/end_to_end/test_scalar_actual_dummy_matrix.py) | Descriptor arguments, module and field handles, strided views, and returned array handles are supported. Deferred-width character views require contiguous storage and a compatible compiler. | +| Allocatable array handles, descriptor arguments, and owned results | Supported | [Allocatables](../guide/allocatables.md) | [Allocatable runtime tests](../../../tests/fortran/allocatables/end_to_end/test_allocatable_handles.py), [scalar-derived matrix tests](../../../tests/fortran/derived_types/end_to_end/test_scalar_actual_dummy_matrix.py) | Module and field handles borrow their owner; returned and caller-created handles own their storage. Numeric and character arrays are supported. Scalar allocatable module variables and fields read as live read-only rank-zero views or `None`, and assigning allocates them. | +| Pointer scalar projections and array handles | Partially supported | [Pointers](../guide/pointers.md) | [Pointer handle tests](../../../tests/fortran/pointers/end_to_end/test_pointer_handles.py), [pointer policy tests](../../../tests/fortran/pointers/policy/test_pointer_ownership_policy.py), [scalar-derived matrix tests](../../../tests/fortran/derived_types/end_to_end/test_scalar_actual_dummy_matrix.py) | Descriptor arguments, module and field handles, strided views, and returned array handles are supported. Scalar pointer module variables and fields read as live read-only rank-zero views or `None`, and assigning writes the current target. Deferred-width character views require contiguous storage and a compatible compiler. | | Array-valued function results | Supported | [Array results](../guide/arrays.md#mutation-and-results) | [Array result tests](../../../tests/fortran/arrays/end_to_end/test_array_results.py) | Ownership and dtype/shape behavior are limited to documented array result forms. | | NumPy array argument contracts | Supported | [Arrays](../guide/arrays.md) | [Array contract tests](../../../tests/fortran/arrays/end_to_end/test_array_contract_validation.py), [multidimensional tests](../../../tests/fortran/arrays/end_to_end/test_layout_and_strided_arrays.py) | Wrong dtype, rank, shape, contiguity, alignment, or mutability is rejected. | | Assumed-type native arguments | Partially supported | [Arrays](../guide/arrays.md#pass-values-to-type-dummies) | [Assumed-type source and edited-contract tests](../../../tests/fortran/assumed_types/end_to_end/test_assumed_type_calls.py) | `AnyNative` accepts PRIK-native NumPy and derived actuals. Descriptor dtypes have a documented subset; assumed-size is rank one, and nonblocking buffer lifetime remains caller-owned. | diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index f78fb3cc4..30ffc309d 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -43,7 +43,6 @@ DerivedWriteback, DirectResultABI, ModuleObjectAccessMechanism, - ModuleStorageAddressMechanism, ModuleGetterAction, NativeArrayDescriptorAttribute, NativeArrayDescriptorKind, @@ -100,6 +99,7 @@ ) from prik.codegen.overloads import OverloadPlanQueries from prik.naming.native_symbols import COLLISION_ADAPTER_STORAGE +from prik.planning.planner import requires_address_capture from prik.planning.models import ( ArrayHandoffPlan, ArgumentTransferPlan, @@ -728,28 +728,10 @@ def _module_defines(self, plan: ModulePlan, needs_native_support: bool) -> tuple # The bundled address-capture primitive needs external linkage for the # Fortran bridge to call it, so the header defines it only where this # macro opts in. Selecting it here keeps it in one translation unit. - if self._requires_address_capture(plan): + if requires_address_capture(plan): definitions.append(CMacroDefinition("PRIK_BINDING_CAPTURE_ADDRESS", "1")) return tuple(definitions) - def _requires_address_capture(self, plan: ModulePlan) -> bool: - """Report whether any borrowed view in this module takes its address in C. - - Both cases name their storage directly rather than reaching it through a - pointer, so neither has a Fortran route to its own address: a module - array whose declaration withheld ``target``, and an array member of a - plain module object, which is likewise not a target. - """ - return any( - variable.storage_address is ModuleStorageAddressMechanism.CAPTURED_ADDRESS - for variable in self._variables(plan) - ) or any( - member.field.access is DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR - for variable in self._variables(plan) - if variable.derived is not None and variable.derived.access is ModuleObjectAccessMechanism.MEMBER_PROXY - for member in variable.derived.member_paths - ) - def _module_includes( self, plan: ModulePlan, @@ -2882,6 +2864,7 @@ def _direct_field_functions( DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE: self._direct_handle_field_functions, DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR: self._direct_array_field_functions, DerivedFieldAccessMechanism.SCALAR_VALUE: self._direct_scalar_field_functions, + DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW: self._direct_scalar_descriptor_field_functions, DerivedFieldAccessMechanism.NESTED_OBJECT: self._direct_nested_field_functions, } try: @@ -2900,6 +2883,7 @@ def _module_member_functions( DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE: self._module_handle_member_functions, DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR: self._module_array_member_functions, DerivedFieldAccessMechanism.SCALAR_VALUE: self._module_scalar_member_functions, + DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW: self._module_scalar_descriptor_member_functions, DerivedFieldAccessMechanism.NESTED_OBJECT: self._module_nested_member_functions, } try: @@ -2932,6 +2916,94 @@ def _direct_scalar_field_functions(self, derived, field) -> tuple[CFunction, ... self._direct_scalar_field_setter(derived, field), ) + def _direct_scalar_descriptor_field_functions(self, derived, field) -> tuple[CFunction, ...]: + """Lend a scalar allocatable or pointer field's storage and assign through it.""" + return self._scalar_descriptor_field_functions( + field, + self._derived_field_bridge_name(derived, field, "get"), + self._derived_field_bridge_name(derived, field, "set"), + self._derived_field_method_name(derived, field, "get"), + self._derived_field_method_name(derived, field, "set"), + owner_nodes=self._derived_owner_address_nodes(derived), + owner_value_nodes=self._derived_owner_and_value_nodes(derived), + leading_arguments=("owner_address",), + ) + + def _module_scalar_descriptor_member_functions(self, variable, member) -> tuple[CFunction, ...]: + """Lend a module object's scalar allocatable or pointer member and assign through it.""" + return self._scalar_descriptor_field_functions( + member.field, + self._module_member_bridge_name(variable, member, "get"), + self._module_member_bridge_name(variable, member, "set"), + self._module_member_method_name(variable, member, "get"), + self._module_member_method_name(variable, member, "set"), + owner_nodes=( + CDeclaration("owner_obj", "PyObject *"), + CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &owner_obj)) return NULL')), + ), + owner_value_nodes=( + CDeclaration("owner_obj", "PyObject *"), + CDeclaration("value_obj", "PyObject *"), + CExpressionStatement( + CodeExpression('if (!PyArg_ParseTuple(args, "OO", &owner_obj, &value_obj)) return NULL') + ), + ), + leading_arguments=(), + ) + + def _scalar_descriptor_field_functions( + self, + field: DerivedFieldPlan, + getter: str, + setter: str, + getter_method: str, + setter_method: str, + *, + owner_nodes: tuple, + owner_value_nodes: tuple, + leading_arguments: tuple[str, ...], + ) -> tuple[CFunction, ...]: + """Build a scalar descriptor field's accessors the way its module-variable form is built.""" + getter_function = self._derived_private_method( + getter_method, + ( + *owner_nodes, + *self._scalar_descriptor_view_nodes( + getter, + leading_arguments, + character=field.string_element, + semantic_type_name=field.semantic_type_name, + owner="owner_obj", + ), + ), + ) + if field.setter_action is not SetterAction.WRITE_THROUGH: + return (getter_function,) + scalar = None if field.string_element else PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name) + setter_function = self._derived_private_method( + setter_method, + ( + *owner_value_nodes, + *self._scalar_descriptor_setter_nodes( + setter, + leading_arguments, + label=f"field {field.name}", + character=field.string_element, + width=field.character_length, + unpack=( + None + if scalar is None + else self._scalar_field_unpack_statement(field, scalar, "value_obj", "value") + ), + semantic_type_name=field.semantic_type_name, + target=field.native_assignment is AssignmentMode.TARGET_COPY, + failure="NULL", + ), + CExpressionStatement(CodeExpression("Py_RETURN_NONE")), + ), + ) + return getter_function, setter_function + def _direct_nested_field_functions(self, derived, field) -> tuple[CFunction, ...]: """Build direct nested field functions from the supplied completed binding records; emitted nodes only project completed binding actions.""" return self._optional_field_functions( @@ -3201,36 +3273,8 @@ def _fixed_string_field_length(field: DerivedFieldPlan) -> int: def _fixed_string_field_input_nodes(self, field: DerivedFieldPlan, object_name: str) -> tuple: """Require exact UTF-8 byte width and reject embedded NULs.""" - length = self._fixed_string_field_length(field) - return ( - CIf( - CodeExpression(f"!PyUnicode_Check({object_name})"), - body=( - CExpressionStatement( - CodeExpression(f'PyErr_SetString(PyExc_TypeError, "Expected str for field {field.name}")') - ), - CReturn(CodeExpression("NULL")), - ), - ), - CDeclaration("value_length", "Py_ssize_t", CodeExpression("0")), - CDeclaration( - "value", - "const char *", - CodeExpression(f"PyUnicode_AsUTF8AndSize({object_name}, &value_length)"), - ), - CIf(CodeExpression("value == NULL"), body=(CReturn(CodeExpression("NULL")),)), - CIf( - CodeExpression(f"value_length != {length} || (Py_ssize_t)strlen(value) != value_length"), - body=( - CExpressionStatement( - CodeExpression( - f'PyErr_SetString(PyExc_TypeError, "Field {field.name} must encode to exactly ' - f'{length} bytes without embedded NUL")' - ) - ), - CReturn(CodeExpression("NULL")), - ), - ), + return self._text_input_nodes( + object_name, f"field {field.name}", self._fixed_string_field_length(field), "NULL" ) def _field_handle_backend_release_nodes(self, field: DerivedFieldPlan, prefix: str) -> tuple: @@ -6243,39 +6287,56 @@ def _lower_module_getter_native_nullable_scalar_view(self, plan: ModuleVariableP The descriptor may be reallocated or reassociated after this read, so Python writes through the setter rather than through the view. """ - owner = self._module_native_array_owner_name(plan) - character = plan.datatype_family is DatatypeFamily.STRING - getter = self._module_bridge_getter_name(plan) - if character: - numpy_type, width = "NPY_STRING", "(int)length" - else: - numpy_type, width = PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).array_numpy_type, "0" return ( CFunction( self._module_getter_name(plan), "PyObject *", storage="static", - body=( - *((CDeclaration("length", "int64_t", CodeExpression("0")),) if character else ()), - CDeclaration( - "data", "void *", CodeExpression(f"{getter}(&length)" if character else f"{getter}()") - ), - CIf( - CodeExpression("data == NULL"), - body=(CExpressionStatement(CodeExpression("Py_RETURN_NONE")),), - ), - *(self._nullable_character_view_width_nodes() if character else ()), - CDeclaration( - "result", - "PyObject *", - CodeExpression( - f"PyArray_New(&PyArray_Type, 0, NULL, {numpy_type}, NULL, data, {width}, " - "NPY_ARRAY_ALIGNED, NULL)" - ), - ), - *self._ordinary_array_field_owner_nodes("result", owner), + body=self._scalar_descriptor_view_nodes( + self._module_bridge_getter_name(plan), + (), + character=plan.datatype_family is DatatypeFamily.STRING, + semantic_type_name=plan.semantic_type_name, + owner=self._module_native_array_owner_name(plan), + ), + ), + ) + + def _scalar_descriptor_view_nodes( + self, + getter: str, + leading_arguments: tuple[str, ...], + *, + character: bool, + semantic_type_name: str, + owner: str, + ) -> tuple: + """Lend one scalar descriptor's current storage read-only, or return ``None``. + + The view keeps ``owner`` alive, which holds the descriptor: a module + variable's module or a field's parent object. + """ + if character: + numpy_type, width = "NPY_STRING", "(int)length" + else: + numpy_type, width = PrimitiveScalarTypeRegistry.type_for(semantic_type_name).array_numpy_type, "0" + arguments = ", ".join((*leading_arguments, *(("&length",) if character else ()))) + return ( + *((CDeclaration("length", "int64_t", CodeExpression("0")),) if character else ()), + CDeclaration("data", "void *", CodeExpression(f"{getter}({arguments})")), + CIf( + CodeExpression("data == NULL"), + body=(CExpressionStatement(CodeExpression("Py_RETURN_NONE")),), + ), + *(self._nullable_character_view_width_nodes() if character else ()), + CDeclaration( + "result", + "PyObject *", + CodeExpression( + f"PyArray_New(&PyArray_Type, 0, NULL, {numpy_type}, NULL, data, {width}, NPY_ARRAY_ALIGNED, NULL)" ), ), + *self._ordinary_array_field_owner_nodes("result", owner), ) @staticmethod @@ -6874,28 +6935,73 @@ def _lower_module_setter_descriptor(self, plan: ModuleVariablePlan) -> tuple[CFu differs from the pointer target's, which become Python exceptions here. """ name = plan.owner_path.rsplit(".", 1)[-1] - setter = self._module_bridge_setter_name(plan) - if plan.datatype_family is DatatypeFamily.STRING: - conversion = self._module_setter_text_nodes(plan, name) - call = f"{setter}(value, (int64_t)value_length)" + character = plan.datatype_family is DatatypeFamily.STRING + return ( + CFunction( + self._module_setter_name(plan), + "int", + parameters=(CParameter("value_obj", "PyObject *"),), + storage="static", + body=( + *self._scalar_descriptor_setter_nodes( + self._module_bridge_setter_name(plan), + (), + label=f"module variable {name}", + character=character, + width=plan.character_length, + unpack=( + None + if character + else self._module_setter_unpack_statement( + plan, PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name) + ) + ), + semantic_type_name=plan.semantic_type_name, + target=plan.binding.native_assignment is AssignmentMode.TARGET_COPY, + failure="-1", + ), + CReturn(CodeExpression("0")), + ), + ), + ) + + def _scalar_descriptor_setter_nodes( + self, + setter: str, + leading_arguments: tuple[str, ...], + *, + label: str, + character: bool, + width: int | None, + unpack: CExpressionStatement | None, + semantic_type_name: str, + target: bool, + failure: str, + ) -> tuple: + """Validate one value and assign it through a scalar allocatable or pointer. + + The bridge reports a disassociated pointer or a character width that + differs from the pointer target's, which become Python exceptions here. + """ + if character: + conversion = self._text_input_nodes("value_obj", label, width, failure) + values = ("value", "(int64_t)value_length") else: - scalar_type = PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name) conversion = ( - CDeclaration("value", scalar_type.c_spelling), - self._module_setter_unpack_statement(plan, scalar_type), + CDeclaration("value", PrimitiveScalarTypeRegistry.type_for(semantic_type_name).c_spelling), + unpack, ) - call = f"{setter}(value)" + values = ("value",) + subject = label[:1].upper() + label[1:] failures = ( ( CIf( CodeExpression("status == 1"), body=( CExpressionStatement( - CodeExpression( - f'PyErr_SetString(PyExc_ValueError, "Module variable {name} has no pointer target")' - ) + CodeExpression(f'PyErr_SetString(PyExc_ValueError, "{subject} has no pointer target")') ), - CReturn(CodeExpression("-1")), + CReturn(CodeExpression(failure)), ), ), CIf( @@ -6903,61 +7009,51 @@ def _lower_module_setter_descriptor(self, plan: ModuleVariablePlan) -> tuple[CFu body=( CExpressionStatement( CodeExpression( - f'PyErr_SetString(PyExc_TypeError, "Module variable {name} must encode to ' + f'PyErr_SetString(PyExc_TypeError, "{subject} must encode to ' "the pointer target's width\")" ) ), - CReturn(CodeExpression("-1")), + CReturn(CodeExpression(failure)), ), ), ) - if plan.binding.native_assignment is AssignmentMode.TARGET_COPY + if target else () ) - return ( - CFunction( - self._module_setter_name(plan), - "int", - parameters=(CParameter("value_obj", "PyObject *"),), - storage="static", - body=( - *conversion, - CDeclaration("status", "int", CodeExpression(call)), - *failures, - CReturn(CodeExpression("0")), - ), - ), - ) + call = f"{setter}({', '.join((*leading_arguments, *values))})" + return (*conversion, CDeclaration("status", "int", CodeExpression(call)), *failures) @staticmethod - def _module_setter_text_nodes(plan: ModuleVariablePlan, name: str) -> tuple: + def _text_input_nodes(object_name: str, label: str, width: int | None, failure: str) -> tuple: """Encode one Python string, requiring the declared width when there is one.""" - width = plan.character_length width_check = f"value_length != {width} || " if width is not None else "" width_text = f"exactly {width} bytes" if width is not None else "bytes" + subject = label[:1].upper() + label[1:] return ( CIf( - CodeExpression("!PyUnicode_Check(value_obj)"), + CodeExpression(f"!PyUnicode_Check({object_name})"), body=( CExpressionStatement( - CodeExpression(f'PyErr_SetString(PyExc_TypeError, "Expected str for module variable {name}")') + CodeExpression(f'PyErr_SetString(PyExc_TypeError, "Expected str for {label}")') ), - CReturn(CodeExpression("-1")), + CReturn(CodeExpression(failure)), ), ), CDeclaration("value_length", "Py_ssize_t", CodeExpression("0")), - CDeclaration("value", "const char *", CodeExpression("PyUnicode_AsUTF8AndSize(value_obj, &value_length)")), - CIf(CodeExpression("value == NULL"), body=(CReturn(CodeExpression("-1")),)), + CDeclaration( + "value", "const char *", CodeExpression(f"PyUnicode_AsUTF8AndSize({object_name}, &value_length)") + ), + CIf(CodeExpression("value == NULL"), body=(CReturn(CodeExpression(failure)),)), CIf( CodeExpression(f"{width_check}(Py_ssize_t)strlen(value) != value_length"), body=( CExpressionStatement( CodeExpression( - f'PyErr_SetString(PyExc_TypeError, "Module variable {name} must encode to ' + f'PyErr_SetString(PyExc_TypeError, "{subject} must encode to ' f'{width_text} without embedded NUL")' ) ), - CReturn(CodeExpression("-1")), + CReturn(CodeExpression(failure)), ), ), ) diff --git a/prik/codegen/docstrings.py b/prik/codegen/docstrings.py index 6cf5b15a2..e1a96ea1c 100644 --- a/prik/codegen/docstrings.py +++ b/prik/codegen/docstrings.py @@ -15,6 +15,7 @@ ArrayPythonLayout, ScalarActualMode, ClassConstructorKind, + DerivedFieldAccessMechanism, EntrypointOptionalityAction, ModuleGetterAction, NativeArrayDescriptorKind, @@ -569,6 +570,8 @@ def field(self, field: DerivedFieldPlan) -> str: lines.append(" The parent wrapper retains the descriptor owner.") elif field.array is not None: lines.append(" Borrowed native view retained by the parent wrapper.") + elif field.access is DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW: + lines.append(f" {_MODULE_SCALAR_VIEW_NOTES[ModuleGetterAction.NATIVE_NULLABLE_SCALAR_VIEW]}") if field.setter_action is SetterAction.WRITE_THROUGH: lines.append(" Assignment writes through to native storage.") elif field.setter_action is SetterAction.REJECT_REPLACEMENT: diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index b4c7d92ad..52cfe1eda 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -10,7 +10,7 @@ from __future__ import annotations from collections.abc import Iterable, Mapping -from dataclasses import replace +from dataclasses import dataclass, replace import re from prik.naming.native_symbols import NativeSymbolNames @@ -82,6 +82,7 @@ FortranTypeDefinition, FortranUse, ) +from prik.planning.planner import has_scalar_descriptor_fields, requires_address_capture from prik.planning.models import ( ArrayHandoffPlan, ArgumentTransferPlan, @@ -121,6 +122,27 @@ _MODULE_SCALAR_CAPTURE_NAME = "prik_capture_scalar_address" # A descriptor setter's result: 0 assigned, 1 no pointer target, 2 width differs. _MODULE_SETTER_STATUS = "prik_setter_status" +# The inquiry reporting whether a scalar descriptor currently has storage, +# keyed by the assignment its completed setter performs. +_SCALAR_DESCRIPTOR_PRESENCE = {AssignmentMode.ALLOCATING_COPY: "allocated", AssignmentMode.TARGET_COPY: "associated"} + + +@dataclass(frozen=True) +class _ScalarDescriptorAccess: + """One scalar allocatable or pointer and how a bridge procedure reaches it. + + ``native`` designates the entity once any owner is associated; a field's + owner arrives as ``parameters`` and is associated by ``prologue``. + """ + + native: str + presence: str + character: bool + semantic_type_name: str + parameters: tuple[FortranParameter, ...] = () + declarations: tuple[FortranDeclaration, ...] = () + prologue: tuple[FortranCall, ...] = () + # The binding answers these from the live descriptor the handle's entry point # supplies, so the bridge emits no procedure of its own for them. @@ -3467,34 +3489,66 @@ def _lower_module_getter_native_scalar_view(self, plan: ModuleVariablePlan) -> t def _lower_module_getter_native_nullable_scalar_view(self, plan: ModuleVariablePlan) -> tuple[FortranFunction, ...]: """Query a scalar descriptor's current storage for one attribute read.""" - native = self._native_variable_name(plan) + return (self._scalar_descriptor_getter(self._module_bridge_getter_name(plan), self._module_descriptor(plan)),) + + def _module_descriptor(self, plan: ModuleVariablePlan) -> _ScalarDescriptorAccess: + """Describe one scalar allocatable or pointer module variable.""" present = {"allocatable": "allocated", "pointer": "associated"}.get(plan.entrypoint.descriptor_kind) if present is None: raise ValueError(f"Scalar descriptor {plan.owner_path!r} has no descriptor kind") - character = plan.datatype_family is DatatypeFamily.STRING - return ( - FortranFunction( - name=self._module_bridge_getter_name(plan), - parameters=((FortranParameter("length", "integer(c_int64_t)", ("intent(out)",)),) if character else ()), - result_name="result", - result_type="type(c_ptr)", - bind_name=self._module_bridge_getter_name(plan), - body=( - FortranAssignment("result", CodeExpression("c_null_ptr")), - *((FortranAssignment("length", CodeExpression("0_c_int64_t")),) if character else ()), - FortranIf( - CodeExpression(f"{present}({native})"), - body=( - *( - (FortranAssignment("length", CodeExpression(f"len({native}, kind=c_int64_t)")),) - if character - else () - ), - FortranAssignment( - "result", - CodeExpression(f"{_MODULE_SCALAR_CAPTURE_NAME}({native})"), - ), + return _ScalarDescriptorAccess( + native=self._native_variable_name(plan), + presence=present, + character=plan.datatype_family is DatatypeFamily.STRING, + semantic_type_name=plan.semantic_type_name, + ) + + @staticmethod + def _field_descriptor(field: DerivedFieldPlan, native: str, **owner) -> _ScalarDescriptorAccess: + """Describe one scalar allocatable or pointer field at ``native``.""" + present = _SCALAR_DESCRIPTOR_PRESENCE.get(field.native_assignment) + if present is None: + raise ValueError(f"Scalar descriptor field {field.owner_path!r} has no descriptor assignment") + return _ScalarDescriptorAccess( + native=native, + presence=present, + character=field.string_element, + semantic_type_name=field.semantic_type_name, + **owner, + ) + + @staticmethod + def _scalar_descriptor_getter(name: str, access: _ScalarDescriptorAccess) -> FortranFunction: + """Return the current storage of a scalar descriptor, or a null address. + + The address is captured on the C side because the storage need not be + a target, and a character also reports its current width. + """ + native = access.native + character = access.character + return FortranFunction( + name=name, + parameters=( + *access.parameters, + *((FortranParameter("length", "integer(c_int64_t)", ("intent(out)",)),) if character else ()), + ), + result_name="result", + result_type="type(c_ptr)", + bind_name=name, + declarations=access.declarations, + body=( + *access.prologue, + FortranAssignment("result", CodeExpression("c_null_ptr")), + *((FortranAssignment("length", CodeExpression("0_c_int64_t")),) if character else ()), + FortranIf( + CodeExpression(f"{access.presence}({native})"), + body=( + *( + (FortranAssignment("length", CodeExpression(f"len({native}, kind=c_int64_t)")),) + if character + else () ), + FortranAssignment("result", CodeExpression(f"{_MODULE_SCALAR_CAPTURE_NAME}({native})")), ), ), ), @@ -3678,16 +3732,24 @@ def _module_array_address(plan: ModuleVariablePlan, native: str) -> str: return f"{_MODULE_ARRAY_CAPTURE_NAME}({native})" raise ValueError(f"Module array view {plan.owner_path!r} has no completed address mechanism: {mechanism!r}") - def _requires_address_capture(self, plan: ModulePlan) -> bool: - """Report whether any borrowed view must take its address on the C side. + def _captures_scalar_address(self, plan: ModulePlan) -> bool: + """Report whether a scalar view or scalar descriptor field takes its address in C.""" + return has_scalar_descriptor_fields(plan) or any( + variable.storage_address is ModuleStorageAddressMechanism.CAPTURED_ADDRESS + and variable.bridge.native_getter_action + in { + ModuleGetterAction.NATIVE_SCALAR_VIEW, + ModuleGetterAction.NATIVE_CHARACTER_VIEW, + ModuleGetterAction.NATIVE_NULLABLE_SCALAR_VIEW, + } + for variable in self._variables(plan) + ) - Both cases name their storage directly rather than reaching it through a - pointer, so neither has a Fortran route to its own address: a module - array whose declaration withheld ``target``, and an array member of a - plain module object, which is likewise not a target. - """ + def _captures_array_address(self, plan: ModulePlan) -> bool: + """Report whether a module array view or plain-object array member takes its address in C.""" return any( variable.storage_address is ModuleStorageAddressMechanism.CAPTURED_ADDRESS + and variable.bridge.native_getter_action is ModuleGetterAction.BORROWED_ARRAY_VIEW for variable in self._variables(plan) ) or any( member.field.access is DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR @@ -3705,19 +3767,10 @@ def _module_array_capture_interfaces(self, plan: ModulePlan) -> tuple[FortranInt address, so C receives where the module variable lives and hands it straight back. Nothing here claims a target or forms a Fortran pointer. """ - if not self._requires_address_capture(plan): + if not requires_address_capture(plan): return () procedures = [] - if any( - variable.storage_address is ModuleStorageAddressMechanism.CAPTURED_ADDRESS - and variable.bridge.native_getter_action - in { - ModuleGetterAction.NATIVE_SCALAR_VIEW, - ModuleGetterAction.NATIVE_CHARACTER_VIEW, - ModuleGetterAction.NATIVE_NULLABLE_SCALAR_VIEW, - } - for variable in self._variables(plan) - ): + if self._captures_scalar_address(plan): procedures.append( FortranInterfaceProcedure( name=_MODULE_SCALAR_CAPTURE_NAME, @@ -3729,15 +3782,7 @@ def _module_array_capture_interfaces(self, plan: ModulePlan) -> tuple[FortranInt bind_c=True, ) ) - if any( - variable.storage_address is ModuleStorageAddressMechanism.CAPTURED_ADDRESS - and variable.bridge.native_getter_action is ModuleGetterAction.BORROWED_ARRAY_VIEW - for variable in self._variables(plan) - ) or any( - member.field.access is DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR - for variable in self._derived_member_proxy_variables(plan) - for member in variable.derived.member_paths - ): + if self._captures_array_address(plan): procedures.append( FortranInterfaceProcedure( name=_MODULE_ARRAY_CAPTURE_NAME, @@ -3874,52 +3919,42 @@ def _lower_module_setter(self, plan: ModuleVariablePlan) -> tuple[FortranFunctio return self._lower_module_setter_value_copy(plan) case AssignmentMode.CHARACTER_COPY: return self._lower_module_setter_character_value(plan) - case AssignmentMode.ALLOCATING_COPY: - return self._lower_module_setter_allocating_copy(plan) - case AssignmentMode.TARGET_COPY: - return self._lower_module_setter_target_copy(plan) + case AssignmentMode.ALLOCATING_COPY | AssignmentMode.TARGET_COPY: + return ( + self._scalar_descriptor_setter( + self._module_bridge_setter_name(plan), self._module_descriptor(plan) + ), + ) raise ValueError(f"Unsupported Fortran module setter assignment for {plan.owner_path!r}: {action!r}") - def _lower_module_setter_allocating_copy(self, plan: ModuleVariablePlan) -> tuple[FortranFunction, ...]: - """Assign into a scalar allocatable; intrinsic assignment allocates it when needed.""" - native = self._native_variable_name(plan) - return self._module_descriptor_setter(plan, (FortranAssignment(native, self._module_setter_value(plan)),)) - - def _lower_module_setter_target_copy(self, plan: ModuleVariablePlan) -> tuple[FortranFunction, ...]: - """Copy into a scalar pointer's current target, reporting an absent or narrower target.""" - native = self._native_variable_name(plan) - assignment: FortranAssignment | FortranIf = FortranAssignment(native, self._module_setter_value(plan)) - if plan.datatype_family is DatatypeFamily.STRING: - assignment = FortranIf( - CodeExpression(f"len({native}, kind=c_int64_t) /= length"), - body=(FortranAssignment(_MODULE_SETTER_STATUS, CodeExpression("2_c_int")),), - else_body=(assignment,), - ) - return self._module_descriptor_setter( - plan, - ( - FortranIf( - CodeExpression(f"associated({native})"), - body=(assignment,), - else_body=(FortranAssignment(_MODULE_SETTER_STATUS, CodeExpression("1_c_int")),), - ), - ), - ) - - def _module_descriptor_setter( - self, - plan: ModuleVariablePlan, - body: tuple[FortranAssignment | FortranIf, ...], - ) -> tuple[FortranFunction, ...]: - """Wrap one descriptor assignment in a setter that reports its status. - - A character arrives as an address and a width, since a descriptor - character's width is only known when Python supplies the value. + @staticmethod + def _scalar_descriptor_setter(name: str, access: _ScalarDescriptorAccess) -> FortranFunction: + """Assign one value through a scalar descriptor and report the status. + + An allocatable takes intrinsic assignment, which allocates it when + needed. A pointer's current target receives the value instead, and an + absent or differently sized target is reported. A character arrives as + an address and a width, since a descriptor character's width is only + known when Python supplies the value. """ - name = self._module_bridge_setter_name(plan) + native = access.native + value = CodeExpression("transfer(bytes, repeat(' ', int(length)))" if access.character else "value") + body: FortranAssignment | FortranIf = FortranAssignment(native, value) + if access.presence == "associated": + if access.character: + body = FortranIf( + CodeExpression(f"len({native}, kind=c_int64_t) /= length"), + body=(FortranAssignment(_MODULE_SETTER_STATUS, CodeExpression("2_c_int")),), + else_body=(body,), + ) + body = FortranIf( + CodeExpression(f"associated({native})"), + body=(body,), + else_body=(FortranAssignment(_MODULE_SETTER_STATUS, CodeExpression("1_c_int")),), + ) declarations: tuple[FortranDeclaration, ...] = () prologue: tuple[FortranCall, ...] = () - if plan.datatype_family is DatatypeFamily.STRING: + if access.character: parameters = ( FortranParameter("value", "type(c_ptr)", ("value",)), FortranParameter("length", "integer(c_int64_t)", ("value",)), @@ -3932,27 +3967,23 @@ def _module_descriptor_setter( ), ) else: - scalar_type = PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name) + scalar_type = PrimitiveScalarTypeRegistry.type_for(access.semantic_type_name) parameters = (FortranParameter("value", scalar_type.fortran_spelling, ("value",)),) - return ( - FortranFunction( - name=name, - parameters=parameters, - result_name=_MODULE_SETTER_STATUS, - result_type="integer(c_int)", - bind_name=name, - declarations=declarations, - body=(FortranAssignment(_MODULE_SETTER_STATUS, CodeExpression("0_c_int")), *prologue, *body), + return FortranFunction( + name=name, + parameters=(*access.parameters, *parameters), + result_name=_MODULE_SETTER_STATUS, + result_type="integer(c_int)", + bind_name=name, + declarations=(*access.declarations, *declarations), + body=( + FortranAssignment(_MODULE_SETTER_STATUS, CodeExpression("0_c_int")), + *access.prologue, + *prologue, + body, ), ) - @staticmethod - def _module_setter_value(plan: ModuleVariablePlan) -> CodeExpression: - """Return the incoming value as the variable's native type.""" - if plan.datatype_family is DatatypeFamily.STRING: - return CodeExpression("transfer(bytes, repeat(' ', int(length)))") - return CodeExpression("value") - def _lower_module_setter_none(self, _plan: ModuleVariablePlan) -> tuple[FortranFunction, ...]: """Return no native setter when the bridge assignment is omitted.""" return () @@ -7740,6 +7771,20 @@ def _direct_field_procedures( return (getter, *((setter,) if setter is not None else ())) if field.access is DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE: return self._direct_native_handle_field_procedures(derived, field) + if field.access is DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW: + access = self._field_descriptor( + field, + f"owner%{field.native_name}", + parameters=(FortranParameter("owner_address", "type(c_ptr)", ("value",)),), + declarations=(self._derived_owner_declaration(derived),), + prologue=(self._derived_owner_association(),), + ) + return self._scalar_descriptor_field_procedures( + field, + access, + self._derived_field_bridge_name(derived, field, "get"), + self._derived_field_bridge_name(derived, field, "set"), + ) if field.access is DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR: getter = self._direct_ordinary_array_field_getter(derived, field) setter = self._direct_ordinary_array_field_setter(derived, field) @@ -7766,6 +7811,13 @@ def _module_member_procedures( return (getter, *((setter,) if setter is not None else ())) if field.access is DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE: return self._module_native_handle_member_procedures(variable, member) + if field.access is DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW: + return self._scalar_descriptor_field_procedures( + field, + self._field_descriptor(field, self._module_member_expression(variable, member)), + self._module_member_bridge_name(variable, member, "get"), + self._module_member_bridge_name(variable, member, "set"), + ) if field.access is DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR: getter = self._module_ordinary_array_member_getter(variable, member) setter = self._module_ordinary_array_member_setter(variable, member) @@ -7779,6 +7831,19 @@ def _module_member_procedures( return (setter,) if setter is not None else () raise ValueError(f"Unsupported Fortran module member lowering for {field.owner_path!r}") + def _scalar_descriptor_field_procedures( + self, + field: DerivedFieldPlan, + access: _ScalarDescriptorAccess, + getter_name: str, + setter_name: str, + ) -> tuple[FortranFunction, ...]: + """Lower a scalar allocatable or pointer field the way its module-variable form is lowered.""" + getter = self._scalar_descriptor_getter(getter_name, access) + if field.setter_action is not SetterAction.WRITE_THROUGH: + return (getter,) + return getter, self._scalar_descriptor_setter(setter_name, access) + def _direct_string_field_getter( self, derived: DerivedTypePlan, diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index 3db24e3d9..8d08d8d46 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -881,6 +881,7 @@ def _derived_field_diagnostics(self, field) -> tuple[WrapperPlanDiagnostic, ...] DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR, DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE, DerivedFieldAccessMechanism.NESTED_OBJECT, + DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW, } else DerivedOwnerRetention.NONE ) @@ -898,7 +899,12 @@ def _derived_field_setter_diagnostics(self, field) -> tuple[WrapperPlanDiagnosti diagnostics = [] if field.setter_role is None: diagnostics.append(self._diagnostic(field.owner_path, "missing-derived-field-setter-role", None)) - if field.native_assignment not in {AssignmentMode.VALUE_COPY, AssignmentMode.ALIAS}: + assignments = ( + {AssignmentMode.ALLOCATING_COPY, AssignmentMode.TARGET_COPY} + if field.access is DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW + else {AssignmentMode.VALUE_COPY, AssignmentMode.ALIAS} + ) + if field.native_assignment not in assignments: diagnostics.append( self._diagnostic(field.owner_path, "invalid-derived-field-assignment", field.native_assignment) ) @@ -910,6 +916,10 @@ def _derived_field_setter_diagnostics(self, field) -> tuple[WrapperPlanDiagnosti def _derived_field_family_diagnostics(self, field) -> tuple[WrapperPlanDiagnostic, ...]: """Dispatch field-facet consistency from its completed object kind.""" match field.object_kind: + case ObjectKind.SCALAR | ObjectKind.STRING if ( + field.access is DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW + ): + valid = field.getter_action is CodegenAction.BORROWED_VIEW and field.rank == 0 case ObjectKind.SCALAR: valid = self._valid_scalar_derived_field(field) case ObjectKind.STRING: diff --git a/prik/planning/entrypoints.py b/prik/planning/entrypoints.py index c1e355c2f..1dc399307 100644 --- a/prik/planning/entrypoints.py +++ b/prik/planning/entrypoints.py @@ -635,6 +635,8 @@ def _field_operations( return self._scalar_field_operations(owner, field, route, owner_path, owner_parameter=True) if field.access is DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE: return self._field_handle_operations(owner, field, route, owner_path, owner_parameter) + if field.access is DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW: + return self._scalar_descriptor_field_operations(owner, field, route, owner_path, owner_parameter) if field.access is DerivedFieldAccessMechanism.FIXED_STRING_COPY: return self._string_field_operations(owner, field, route, owner_path, owner_parameter) if field.access is DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR: @@ -693,6 +695,32 @@ def _nested_module_field_operations(self, owner, field, route, owner_path): ), ) + def _scalar_descriptor_field_operations(self, owner, field, route, owner_path, owner_parameter): + """Plan the current-storage getter and status-reporting setter of a scalar descriptor field.""" + owner_values = (self._opaque_parameter("owner", fortran_name="owner_address"),) if owner_parameter else () + width = (self._int64_parameter("length", reference=True, intent="out"),) if field.string_element else () + operations = [ + self._operation( + owner_path, + f"field:{route}:get", + self._field_symbol(owner, field, route, "get"), + (*owner_values, *width), + self._opaque_result(), + ) + ] + if field.setter_action is SetterAction.WRITE_THROUGH: + values, result = self._scalar_descriptor_setter_signature(field.string_element, field.semantic_type_name) + operations.append( + self._operation( + owner_path, + f"field:{route}:set", + self._field_symbol(owner, field, route, "set"), + (*owner_values, *values), + result, + ) + ) + return tuple(operations) + def _string_field_operations(self, owner, field, route, owner_path, owner_parameter): owner_values = (self._opaque_parameter("owner", fortran_name="owner_address"),) if owner_parameter else () output = self._value( @@ -1195,8 +1223,15 @@ def _module_setter_signature(self, variable): return (value,), None if assignment not in {AssignmentMode.ALLOCATING_COPY, AssignmentMode.TARGET_COPY}: return (self._scalar_parameter(variable.semantic_type_name),), None - if variable.datatype_family is not DatatypeFamily.STRING: - return (self._scalar_parameter(variable.semantic_type_name),), self._int_result() + return self._scalar_descriptor_setter_signature( + variable.datatype_family is DatatypeFamily.STRING, + variable.semantic_type_name, + ) + + def _scalar_descriptor_setter_signature(self, character: bool, semantic_type_name: str): + """Return the values and status result of a scalar allocatable or pointer assignment.""" + if not character: + return (self._scalar_parameter(semantic_type_name),), self._int_result() value = self._value("value", NativeEntrypointABIValueKind.OPAQUE, pointer_depth=1, const=True) return (value, self._int64_parameter("length")), self._int_result() diff --git a/prik/planning/planner.py b/prik/planning/planner.py index 02daa82f6..a85f58275 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -37,6 +37,7 @@ DirectResultABI, ModuleGetterAction, ModuleObjectAccessMechanism, + ModuleStorageAddressMechanism, ModuleVariablePolicy, NativeArraySourceKind, OverloadPolicy, @@ -161,6 +162,43 @@ _ALIASABLE_REEXPORT_KINDS = frozenset({"procedure", "generic", "derived_type"}) +def _member_proxy_fields(plan: ModulePlan) -> tuple[DerivedFieldPlan, ...]: + """Return the fields a plain module object reaches through its member proxy.""" + return tuple( + member.field + for variable in plan.variables + if variable.derived is not None and variable.derived.access is ModuleObjectAccessMechanism.MEMBER_PROXY + for member in variable.derived.member_paths + ) + + +def has_scalar_descriptor_fields(plan: ModulePlan) -> bool: + """Read whether a planned field lends scalar allocatable or pointer storage.""" + fields = ( + *(field for namespace in plan.namespaces for derived in namespace.derived_types for field in derived.fields), + *_member_proxy_fields(plan), + ) + return any(field.access is DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW for field in fields) + + +def requires_address_capture(plan: ModulePlan) -> bool: + """Read whether a borrowed view in the plan takes its storage address in C. + + None of these names storage Fortran can take the address of: a module + variable whose declaration withheld ``target``, an array member of a plain + module object, and a scalar allocatable or pointer field, which need not be + a target either. + """ + return ( + any(variable.storage_address is ModuleStorageAddressMechanism.CAPTURED_ADDRESS for variable in plan.variables) + or any( + field.access is DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR + for field in _member_proxy_fields(plan) + ) + or has_scalar_descriptor_fields(plan) + ) + + def requires_cfi_header(namespaces: tuple[NamespacePlan, ...]) -> bool: """Read completed plans that require the standard C descriptor header.""" accepts = {NativeArraySourceKind.ALLOCATABLE_HANDLE, NativeArraySourceKind.POINTER_HANDLE} diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 4063f5aaf..5a3ab2a8c 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -355,9 +355,10 @@ def build_derived_field_policy( origin=DerivedObjectOrigin.BORROWED_FIELD, ) array = _array_handoff_policy(field.semantic_type) + access = _derived_field_access_mechanism(getter, handle) blockers = ( *_runtime_semantic_validation_blockers(field.semantic_type, f"field {field.name!r}"), - *_derived_field_blockers(field, getter, setter, handle, array), + *_derived_field_blockers(field, getter, setter, handle, array, access), ) return DerivedFieldPolicy( owner_path=field_path, @@ -367,13 +368,13 @@ def build_derived_field_policy( string_element=field.semantic_type.name == "String", rank=int(field.semantic_type.rank or 0), object_kind=getter.kind, - access=_derived_field_access_mechanism(getter.kind, handle), + access=access, getter=getter, setter=setter, getter_action=getter.codegen_action, setter_action=setter.setter_action, native_assignment=setter.assignment_mode, - owner_retention=_derived_field_owner_retention(getter.kind, handle), + owner_retention=_derived_field_owner_retention(getter.kind, access), character_length=_character_length(field.semantic_type), array=array, native_array_handle=handle, @@ -384,26 +385,38 @@ def build_derived_field_policy( def _derived_field_access_mechanism( - object_kind: ObjectKind, + getter: OwnershipDecision, handle: NativeArrayHandleWrapperPolicy | None, ) -> DerivedFieldAccessMechanism: - """Complete the typed field bridge mechanism before wrapper planning.""" + """Complete the typed field bridge mechanism before wrapper planning. + + A nullable borrowed scalar or string getter is a scalar allocatable or + pointer field, which lends its current storage rather than a copy. + """ if handle is not None: return DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE + if ( + getter.kind in {ObjectKind.SCALAR, ObjectKind.STRING} + and getter.transfer is TransferMode.BORROWED_VIEW + and getter.nullable + ): + return DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW return { ObjectKind.SCALAR: DerivedFieldAccessMechanism.SCALAR_VALUE, ObjectKind.STRING: DerivedFieldAccessMechanism.FIXED_STRING_COPY, ObjectKind.NUMPY_ARRAY: DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR, ObjectKind.DERIVED_TYPE: DerivedFieldAccessMechanism.NESTED_OBJECT, - }[object_kind] + }[getter.kind] def _derived_field_owner_retention( object_kind: ObjectKind, - handle: NativeArrayHandleWrapperPolicy | None, + access: DerivedFieldAccessMechanism, ) -> DerivedOwnerRetention: """Complete whether a returned field object must keep its parent alive.""" - if handle is not None or object_kind in {ObjectKind.NUMPY_ARRAY, ObjectKind.DERIVED_TYPE}: + if access in {DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE, DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW}: + return DerivedOwnerRetention.PARENT_WRAPPER + if object_kind in {ObjectKind.NUMPY_ARRAY, ObjectKind.DERIVED_TYPE}: return DerivedOwnerRetention.PARENT_WRAPPER return DerivedOwnerRetention.NONE @@ -687,7 +700,7 @@ def _class_constructor_policy( setter_action=field.setter_action, ) for field in derived.fields - if field.object_kind is ObjectKind.SCALAR + if field.access is DerivedFieldAccessMechanism.SCALAR_VALUE and field.semantic_type_name in _PLAN_PRIMITIVE_SCALAR_TYPES and field.setter_action is SetterAction.WRITE_THROUGH ) @@ -843,13 +856,14 @@ def _derived_field_blockers( setter: OwnershipDecision, handle: NativeArrayHandleWrapperPolicy | None, array: ArrayHandoffPolicy | None, + access: DerivedFieldAccessMechanism, ) -> list[str]: """Return exact unsupported public-field forms before lowering.""" return [ *_derived_field_completed_policy_blockers(field, getter, setter), *_derived_field_descriptor_blockers(field, handle), - *_derived_field_object_kind_blockers(field, getter), - *_derived_field_setter_blockers(field, setter), + *_derived_field_object_kind_blockers(field, getter, access), + *_derived_field_setter_blockers(field, setter, access), *_persistent_array_extent_blockers(f"field {field.name!r}", array), ] @@ -887,12 +901,16 @@ def _derived_field_descriptor_blockers( def _derived_field_object_kind_blockers( field: models.SemanticField, getter: OwnershipDecision, + access: DerivedFieldAccessMechanism, ) -> list[str]: """Return blockers selected by the completed public field object kind.""" semantic_type = field.semantic_type rank = int(semantic_type.rank or 0) blockers: list[str] = [] - if getter.kind is ObjectKind.NUMPY_ARRAY: + if access is DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW: + if semantic_type.name not in _PLAN_PRIMITIVE_SCALAR_TYPES | {"String"}: + blockers.append(f"field {field.name!r} is not a primitive or character scalar descriptor") + elif getter.kind is ObjectKind.NUMPY_ARRAY: if semantic_type.name not in _PLAN_PRIMITIVE_SCALAR_TYPES | {"String"}: blockers.append(f"field {field.name!r} is an unsupported array of derived values") elif getter.kind is ObjectKind.DERIVED_TYPE: @@ -915,12 +933,15 @@ def _derived_field_object_kind_blockers( def _derived_field_setter_blockers( field: models.SemanticField, setter: OwnershipDecision, + access: DerivedFieldAccessMechanism, ) -> list[str]: """Return blockers for an incomplete native write-through assignment.""" - if setter.setter_action is SetterAction.WRITE_THROUGH and setter.assignment_mode not in { - AssignmentMode.VALUE_COPY, - AssignmentMode.ALIAS, - }: + expected = ( + _SCALAR_DESCRIPTOR_ASSIGNMENTS.get(_scalar_descriptor_kind(field.semantic_type) or "", set()) + if access is DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW + else {AssignmentMode.VALUE_COPY, AssignmentMode.ALIAS} + ) + if setter.setter_action is SetterAction.WRITE_THROUGH and setter.assignment_mode not in expected: return [f"field {field.name!r} has no completed native setter assignment"] return [] @@ -960,7 +981,7 @@ def build_module_variable_policy( # Gather semantic decisions shared by all module-variable policy families. getter = _ownership_decision(variable, models.RESOLVED_GETTER_OWNERSHIP_POLICY_METADATA) setter = _ownership_decision(variable, models.RESOLVED_SETTER_OWNERSHIP_POLICY_METADATA) - descriptor_kind = _scalar_module_descriptor_kind(variable) + descriptor_kind = _scalar_descriptor_kind(variable.semantic_type) constant = _is_scalar_module_constant(variable) native_array_handle = _native_array_handle_wrapper_policy( variable.semantic_type, @@ -3472,10 +3493,13 @@ def _completed_argument_blockers( blockers.extend(_derived_argument_handoff_blockers(argument, derived, derived_types)) requires_holder = bool( derived_call is not None - and any(case.action is DerivedCallAction.ALLOCATABLE_HOLDER for case in derived_call.cases) + and any( + case.action in {DerivedCallAction.ALLOCATABLE_HOLDER, DerivedCallAction.POINTER_HOLDER} + for case in derived_call.cases + ) ) blockers.extend( - _allocatable_holder_field_blockers( + _holder_field_blockers( f"argument {argument.name!r}", derived, derived_types, @@ -3572,11 +3596,11 @@ def _direct_result_policy(context: _FunctionPolicyContext) -> _ResultPolicyCandi ) blockers.extend(_derived_type_definition_blockers("result", derived, context.derived_types)) blockers.extend( - _allocatable_holder_field_blockers( + _holder_field_blockers( "result", derived, context.derived_types, - required=bool(derived is not None and derived.storage is DerivedObjectStorage.ALLOCATABLE_HOLDER), + required=bool(derived is not None and derived.storage in _HOLDER_STORAGE), ) ) return _ResultPolicyCandidate( @@ -3794,11 +3818,11 @@ def _hidden_result_candidate( blockers = ( *blockers, *_derived_type_definition_blockers(label, derived, context.derived_types), - *_allocatable_holder_field_blockers( + *_holder_field_blockers( label, derived, context.derived_types, - required=bool(derived is not None and derived.storage is DerivedObjectStorage.ALLOCATABLE_HOLDER), + required=bool(derived is not None and derived.storage in _HOLDER_STORAGE), ), ) @@ -4520,21 +4544,26 @@ def _derived_type_definition_blockers( return (f"{label} has no completed wrapper type definition for {derived.type_identity!r}",) -def _allocatable_holder_field_blockers( +# A derived object held through an allocatable or pointer descriptor reaches +# its fields only through the holder's scalar-member procedures. +_HOLDER_STORAGE = frozenset({DerivedObjectStorage.ALLOCATABLE_HOLDER, DerivedObjectStorage.POINTER_HOLDER}) + + +def _holder_field_blockers( label: str, derived: DerivedHandoffPolicy | None, derived_types: Mapping[tuple[str, str], DerivedTypePolicy], *, required: bool, ) -> tuple[str, ...]: - """Keep the first holder slice within its completed scalar-member policy.""" + """Keep an allocatable or pointer holder within its completed scalar-member policy.""" if not required or derived is None: return () type_policy = derived_types.get(derived.type_identity) if type_policy is None: return () return tuple( - f"{label} allocatable holder field {field.name!r} requires unsupported {field.access.value} access" + f"{label} holder field {field.name!r} requires unsupported {field.access.value} access" for field in type_policy.fields if field.access is not DerivedFieldAccessMechanism.SCALAR_VALUE ) @@ -7112,7 +7141,7 @@ def _scalar_module_initializer_blockers( return tuple(blockers) -_SCALAR_MODULE_DESCRIPTOR_ASSIGNMENTS = { +_SCALAR_DESCRIPTOR_ASSIGNMENTS = { "allocatable": {AssignmentMode.ALLOCATING_COPY}, "pointer": {AssignmentMode.TARGET_COPY}, } @@ -7129,7 +7158,7 @@ def _scalar_module_setter_blockers( return ("scalar constant must omit native setter assignment",) return () if setter.setter_action is SetterAction.WRITE_THROUGH: - expected_assignments = _SCALAR_MODULE_DESCRIPTOR_ASSIGNMENTS.get( + expected_assignments = _SCALAR_DESCRIPTOR_ASSIGNMENTS.get( descriptor_kind, {AssignmentMode.VALUE_COPY, AssignmentMode.CHARACTER_COPY}, ) @@ -7158,7 +7187,7 @@ def _scalar_module_getter_action( if _source_parameter_needs_native_getter(variable): return ModuleGetterAction.NATIVE_CONSTANT_VALUE return ModuleGetterAction.CONSTANT_VALUE - if _scalar_module_descriptor_kind(variable) is not None: + if _scalar_descriptor_kind(variable.semantic_type) is not None: return ModuleGetterAction.NATIVE_NULLABLE_SCALAR_VIEW if getter is not None and getter.codegen_action is CodegenAction.SNAPSHOT_COPY and getter.nullable: return ModuleGetterAction.NULLABLE_SNAPSHOT @@ -7212,16 +7241,6 @@ def _scalar_module_native_assignment( return setter.assignment_mode -def _scalar_module_descriptor_kind(variable: models.SemanticVariable) -> str | None: - """Return the scalar descriptor family recorded on a module variable, if any.""" - metadata = variable.semantic_type.metadata - if metadata.get("fortran_allocatable"): - return "allocatable" - if metadata.get("fortran_pointer"): - return "pointer" - return None - - def _is_scalar_module_constant(variable: models.SemanticVariable) -> bool: """Report whether a module variable is constrained as a semantic constant.""" return any(constraint.name == "Constant" for constraint in variable.semantic_type.constraints) diff --git a/prik/policy/models.py b/prik/policy/models.py index 4a6b52428..3e86cb929 100644 --- a/prik/policy/models.py +++ b/prik/policy/models.py @@ -377,9 +377,15 @@ class ModuleObjectAccessMechanism(str, Enum): class DerivedFieldAccessMechanism(str, Enum): - """Typed bridge mechanism for one public live derived field.""" + """Typed bridge mechanism for one public live derived field. + + ``SCALAR_DESCRIPTOR_VIEW`` lends a scalar allocatable or pointer field's + current storage as a read-only rank-zero view, or ``None``; Python writes + it only through the setter's allocating or target-copy assignment. + """ SCALAR_VALUE = "scalar_value" + SCALAR_DESCRIPTOR_VIEW = "scalar_descriptor_view" FIXED_STRING_COPY = "fixed_string_copy" ORDINARY_ARRAY_DESCRIPTOR = "ordinary_array_descriptor" NATIVE_ARRAY_HANDLE = "native_array_handle" diff --git a/prik/policy/ownership.py b/prik/policy/ownership.py index 01075767b..d212ff33c 100644 --- a/prik/policy/ownership.py +++ b/prik/policy/ownership.py @@ -977,7 +977,7 @@ def decide_semantic_getter( return storage if context.is_module_variable and variable.semantic_type.metadata.get("native_storage"): return storage - if context.is_module_variable and ( + if (context.is_module_variable or context.is_field) and ( variable.semantic_type.metadata.get("fortran_allocatable") or variable.semantic_type.metadata.get("fortran_pointer") ): @@ -1011,7 +1011,7 @@ def decide_semantic_setter( setter_action=SetterAction.OMIT, ) incoming = self.decide_semantic_type(variable.semantic_type, OwnershipContext.argument()) - descriptor_assignment = self._module_scalar_descriptor_assignment(storage, context, variable) + descriptor_assignment = self._scalar_descriptor_assignment(storage, context, variable) if descriptor_assignment is not None: return replace( incoming, @@ -1031,18 +1031,23 @@ def decide_semantic_setter( ) @staticmethod - def _module_scalar_descriptor_assignment( + def _scalar_descriptor_assignment( storage: OwnershipDecision, context: OwnershipContext, variable: Any, ) -> AssignmentMode | None: - """Select how a scalar allocatable or pointer module variable is assigned. + """Select how a scalar allocatable or pointer module variable or field is assigned. Its getter lends a read-only view of the current storage, so Python writes only through the setter: an allocatable takes intrinsic assignment, while a pointer's current target receives the value. """ - if not context.is_module_variable or storage.kind not in {ObjectKind.SCALAR, ObjectKind.STRING}: + if not (context.is_module_variable or context.is_field): + return None + if ( + storage.kind not in {ObjectKind.SCALAR, ObjectKind.STRING} + or storage.transfer is not TransferMode.BORROWED_VIEW + ): return None metadata = variable.semantic_type.metadata if metadata.get("fortran_allocatable"): @@ -1965,6 +1970,18 @@ def _module_constant_decision(self, facts: _StorageFacts) -> OwnershipDecision: def _derived_field_decision(self, facts: _StorageFacts, context: OwnershipContext) -> OwnershipDecision: """Return policy for storage that remains owned by the containing derived wrapper.""" + if (facts.allocatable or facts.pointer) and facts.rank == 0 and not facts.is_custom: + return OwnershipDecision( + self._kind(facts, OwnershipContext()), + OwnershipOwner.WRAPPER, + TransferMode.BORROWED_VIEW, + DestructionPolicy.WRAPPER_DEALLOC, + storage_mode=StorageMode.ALIAS, + boundary_storage_mode=StorageMode.ALIAS, + nullable=True, + borrowed=True, + reason="scalar field descriptor lends a read-only view of its current storage on each read", + ) if facts.allocatable and facts.rank == 0: return self._allocatable_scalar_decision(facts, context) if facts.pointer and facts.rank == 0: @@ -2281,7 +2298,9 @@ def _pointer_container_blocker( "use PointerPolicy for extraction and descriptor operations" ) return None - required_transfer = TransferMode.BORROWED_VIEW if context.is_module_variable else TransferMode.SNAPSHOT_COPY + required_transfer = ( + TransferMode.SNAPSHOT_COPY if context.is_field and facts.is_custom else TransferMode.BORROWED_VIEW + ) if decision.transfer is not required_transfer: return f"scalar pointer {context.location} accessor requires {required_transfer.value} transfer" return None diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index b816aa3cd..f46c97cdd 100644 --- a/prik/printers/pyi.py +++ b/prik/printers/pyi.py @@ -1479,13 +1479,19 @@ def _constructor_argument( @staticmethod def _constructor_accepts_field(field: SemanticVariable) -> bool: - """Handle constructor accepts field for the current generation context.""" + """Return whether the keyword-field constructor takes one field. + + A scalar allocatable or pointer field starts without storage, so it is + assigned after construction rather than passed as a keyword. + """ semantic_type = field.semantic_type return ( field.visibility == "public" and semantic_type.rank == 0 and semantic_type.name != "String" and semantic_type.name in NumpyDtypeRegistry.TYPES + and not semantic_type.metadata.get("fortran_allocatable") + and not semantic_type.metadata.get("fortran_pointer") ) @staticmethod diff --git a/tests/fortran/derived_types/end_to_end/fixtures/native/fderived_scalar_descriptor_fields.f90 b/tests/fortran/derived_types/end_to_end/fixtures/native/fderived_scalar_descriptor_fields.f90 new file mode 100644 index 000000000..e1e56d8c1 --- /dev/null +++ b/tests/fortran/derived_types/end_to_end/fixtures/native/fderived_scalar_descriptor_fields.f90 @@ -0,0 +1,34 @@ +module scalar_descriptor_fields + implicit none + + type :: record + real(8) :: plain = 0 + real(8), allocatable :: scale + real(8), pointer :: weight => null() + character(len=:), allocatable :: name + character(len=4), pointer :: tag => null() + end type record + + type(record) :: shared +contains + subroutine fill(item) + type(record), intent(inout) :: item + item%scale = 1.5d0 + allocate(item%weight) + item%weight = 2.5d0 + item%name = 'hello' + allocate(item%tag) + item%tag = 'abcd' + end subroutine fill + + function total(item) result(value) + type(record), intent(in) :: item + real(8) :: value + value = item%scale + item%weight + end function total + + function shared_name() result(value) + character(len=:), allocatable :: value + value = shared%name + end function shared_name +end module scalar_descriptor_fields 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 2970597d2..0758364f9 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 @@ -8,6 +8,7 @@ import pytest from tests.fortran._support.wrapper_build import ( + _build_generated_pyi_and_import, _build_source_and_import, _compile_native_object, _import_from_build_dir, @@ -50,6 +51,7 @@ def current_label() -> String[8]: ... def reset_label() -> None: ... """ VALUE_AND_OPTIONAL_SOURCE = (NATIVE_FIXTURES / "fderived_value_optional.f90").read_text(encoding="utf-8") +SCALAR_DESCRIPTOR_FIELD_SOURCE = NATIVE_FIXTURES / "fderived_scalar_descriptor_fields.f90" VALUE_AND_OPTIONAL_CONTRACT = """\ from prik.contracts import Arg, Float64, Returns, Value, native_abi, native_call @@ -196,6 +198,71 @@ def test_fixed_string_fields_use_canonical_plan(tmp_path: Path): current.label = "short" +def _assert_scalar_descriptor_fields(module) -> None: + """Check live storage, assignment, and failures of scalar allocatable and pointer fields.""" + record = module.Record() + assert (record.scale, record.weight, record.name, record.tag) == (None, None, None, None) + with pytest.raises(TypeError, match="unexpected keyword argument 'scale'"): + module.Record(scale=np.float64(1.0)) + with pytest.raises(ValueError, match="Field weight has no pointer target"): + record.weight = np.float64(1.0) + + module.fill(record) + weight = record.weight + assert weight.shape == () and weight.dtype == np.float64 and float(weight) == 2.5 + assert record.name.dtype == np.dtype("S5") and record.name[()] == b"hello" + with pytest.raises(ValueError, match="read-only"): + weight[...] = 0.0 + + # Assignment allocates or writes the current target, and the earlier view + # of the pointer target sees the write. + record.scale = np.float64(10.0) + record.weight = np.float64(4.0) + record.name = "longer name" + assert float(weight) == 4.0 + assert module.total(record) == 14.0 + assert record.name[()] == b"longer name" + with pytest.raises(TypeError, match="exactly 4 bytes"): + record.tag = "toolong" + record.tag = "wxyz" + assert record.tag[()] == b"wxyz" + + # A view keeps its parent alive. + scale = record.scale + del record + assert float(scale) == 10.0 + + # A plain module object reaches the same fields through its members. + shared = module.shared + assert shared.name is None + shared.name = "abc" + assert module.shared_name() == "abc" + + +def test_scalar_descriptor_fields_lend_live_storage_in_source_and_contract_builds(tmp_path: Path): + source_module = _build_source_and_import( + SCALAR_DESCRIPTOR_FIELD_SOURCE, + tmp_path / "source", + { + "bind_c_fderived_scalar_descriptor_fields_wrapper.f90", + "fderived_scalar_descriptor_fields_wrapper.c", + "fderived_scalar_descriptor_fields_wrapper.h", + }, + ) + contract_module = _build_generated_pyi_and_import(SCALAR_DESCRIPTOR_FIELD_SOURCE, tmp_path / "contract") + + for module in (source_module, contract_module): + _assert_scalar_descriptor_fields(module) + + # The generated contract's constructor states the keywords both builds accept. + contract = (tmp_path / "contract" / "contracts" / "fderived_scalar_descriptor_fields").rglob("*.pyi") + constructor = next( + text for text in (path.read_text(encoding="utf-8") for path in contract) if "class Record" in text + ) + assert "plain: Float64 = 0" in constructor + assert "scale: Allocatable[Float64]\n" in constructor and "scale: Allocatable[Float64] =" not in constructor + + def test_value_copy_and_optional_derived_inputs_match_source_oracle(tmp_path: Path): source = tmp_path / "source" / "derived_value_arguments.f90" source.parent.mkdir() diff --git a/tests/fortran/derived_types/policy/test_derived_accessor_policy.py b/tests/fortran/derived_types/policy/test_derived_accessor_policy.py index 3733e2d6e..6df7e61bc 100644 --- a/tests/fortran/derived_types/policy/test_derived_accessor_policy.py +++ b/tests/fortran/derived_types/policy/test_derived_accessor_policy.py @@ -1,5 +1,7 @@ """Tests split by stable ownership concept from `test_handle_policy_dispatch.py`.""" +import pytest + from prik.semantics.models import ( RESOLVED_SETTER_OWNERSHIP_POLICY_METADATA, SemanticClass, @@ -20,6 +22,7 @@ from prik.semantics.models import ( RESOLVED_DERIVED_TYPE_POLICY_METADATA, + RESOLVED_FUNCTION_WRAPPER_POLICY_METADATA, ) @@ -75,3 +78,24 @@ class holder: policy = module.classes[1].metadata[RESOLVED_DERIVED_TYPE_POLICY_METADATA] assert policy.supported is False assert "field 'values' is an unsupported array of derived values" in policy.blockers + + +@pytest.mark.parametrize("holder", ["Allocatable", "Pointer"]) +def test_descriptor_holder_rejects_fields_it_cannot_reach(holder): + """A held object reaches only scalar-value fields, whichever descriptor holds it.""" + module = parse_pyi_text( + f""" +class item: + tag: String[4] + scale: Allocatable[Float64] + +@native_call([{holder}(Arg(0))]) +def attach(value: item | None) -> None: ... +""", + module_name="descriptor_holder", + ) + complete_semantic_policies(module) + + policy = module.functions[0].metadata[RESOLVED_FUNCTION_WRAPPER_POLICY_METADATA] + assert "argument 'value' holder field 'tag' requires unsupported fixed_string_copy access" in policy.blockers + assert "argument 'value' holder field 'scale' requires unsupported scalar_descriptor_view access" in policy.blockers diff --git a/tests/fortran/infrastructure/policy/test_policy_completion.py b/tests/fortran/infrastructure/policy/test_policy_completion.py index 9bbf2d30b..63ae1cc51 100644 --- a/tests/fortran/infrastructure/policy/test_policy_completion.py +++ b/tests/fortran/infrastructure/policy/test_policy_completion.py @@ -6,6 +6,7 @@ ADDRESS_ROLE_PROJECTION, ) from prik.semantics.models import ( + RESOLVED_DERIVED_TYPE_POLICY_METADATA, RESOLVED_GETTER_OWNERSHIP_POLICY_METADATA, RESOLVED_OWNERSHIP_POLICY_METADATA, RESOLVED_RETURN_OWNERSHIP_POLICY_METADATA, @@ -29,6 +30,7 @@ TransferMode, ) from prik.policy.completion import complete_semantic_policies +from prik.policy.models import DerivedFieldAccessMechanism from tests.fortran._support.ownership_policy import ( _scalar_type, parse_pyi_text, @@ -114,11 +116,11 @@ def test_scalar_accessor_policies_are_complete_before_ir_lowering(): assert setter.setter_action is SetterAction.WRITE_THROUGH -def test_scalar_descriptor_fields_snapshot_while_module_variables_lend_current_storage(): - """A descriptor field copies its value; a module descriptor lends its current storage. +def test_scalar_descriptor_fields_and_module_variables_lend_current_storage(): + """A scalar allocatable or pointer lends its current storage wherever it lives. - A module variable's setter assigns through the descriptor instead: an - allocatable is allocated when needed and a pointer writes its target. + Its setter assigns through the descriptor instead: an allocatable is + allocated when needed and a pointer writes its target. """ module = parse_pyi_text( """ @@ -136,32 +138,23 @@ class point: alloc_module, ptr_module = module.variables alloc_field, ptr_field = module.classes[0].fields - for field in (alloc_field, ptr_field): - storage = field.metadata[RESOLVED_OWNERSHIP_POLICY_METADATA] - getter = field.metadata[RESOLVED_GETTER_OWNERSHIP_POLICY_METADATA] - assert storage.transfer is TransferMode.SNAPSHOT_COPY - assert storage.nullable is True - assert storage.codegen_action is CodegenAction.SNAPSHOT_COPY - assert getter.transfer is TransferMode.SNAPSHOT_COPY - assert getter.nullable is True - assert ( - field.metadata[RESOLVED_SETTER_OWNERSHIP_POLICY_METADATA].setter_action is SetterAction.REJECT_REPLACEMENT - ) - assert alloc_field.metadata[RESOLVED_OWNERSHIP_POLICY_METADATA].storage_mode is StorageMode.HEAP - assert ptr_field.metadata[RESOLVED_OWNERSHIP_POLICY_METADATA].storage_mode is StorageMode.ALIAS - - for variable, assignment in ( + for entity, assignment in ( (alloc_module, AssignmentMode.ALLOCATING_COPY), (ptr_module, AssignmentMode.TARGET_COPY), + (alloc_field, AssignmentMode.ALLOCATING_COPY), + (ptr_field, AssignmentMode.TARGET_COPY), ): - getter = variable.metadata[RESOLVED_GETTER_OWNERSHIP_POLICY_METADATA] - setter = variable.metadata[RESOLVED_SETTER_OWNERSHIP_POLICY_METADATA] + getter = entity.metadata[RESOLVED_GETTER_OWNERSHIP_POLICY_METADATA] + setter = entity.metadata[RESOLVED_SETTER_OWNERSHIP_POLICY_METADATA] assert getter.transfer is TransferMode.BORROWED_VIEW assert getter.nullable is True assert getter.storage_mode is StorageMode.ALIAS assert setter.setter_action is SetterAction.WRITE_THROUGH assert setter.assignment_mode is assignment + fields = module.classes[0].metadata[RESOLVED_DERIVED_TYPE_POLICY_METADATA].fields + assert {field.access for field in fields} == {DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW} + def test_scalar_descriptor_function_boundaries_use_normal_scalar_values(): module = parse_pyi_text( From 1a514deb043bde950a2e3b0b492f12ad2f2162d8 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 27 Sep 2026 14:00:23 +0100 Subject: [PATCH 02/11] codex: lend every scalar field and descriptor scalar as a writable view Scalar fields of a Fortran derived type now read as live rank-zero NumPy views of the object's storage, the way module variables do: numeric and logical fields as T[()] views and fixed-length character fields as fixed-width bytes views, spelled T[()] and String[n][()] in generated contracts. An edited contract that keeps a plain T or String[n] field still reads a copied value. Direct objects, module-object members, and objects held through allocatable or pointer descriptors all lend the same storage. Scalar allocatable and pointer module variables and fields now lend writable views, matching allocatable and pointer array views: a view is valid until native code reallocates, deallocates, or reassociates it, and assigning the attribute still reaches the current storage. The bridge's field dispatchers become table dispatch over named builders, and one address-capture reader in the planner serves both backends. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 29 +- docs/index.md | 8 +- docs/user/guide/allocatables.md | 14 +- docs/user/guide/pointers.md | 12 +- docs/user/guide/wrapping-derived-types.md | 25 +- docs/user/guide/wrapping-modules.md | 6 +- docs/user/language-support/feature-matrix.md | 8 +- docs/user/reference/pyi-format.md | 34 +- prik/codegen/c/binding.py | 205 ++++++++---- prik/codegen/docstrings.py | 4 +- prik/codegen/fortran/bridge.py | 300 ++++++++++++------ prik/pipeline/wrapper.py | 29 +- prik/planning/entrypoints.py | 43 +++ prik/planning/planner.py | 12 +- prik/policy/construction.py | 40 ++- prik/policy/models.py | 11 +- prik/policy/ownership.py | 24 +- prik/semantics/fortran2ir.py | 2 +- prik/semantics/pyi2ir.py | 17 +- .../end_to_end/test_scalar_allocatables.py | 8 +- .../fcallback_all_f90/fcallback_all_f90.pyi | 4 +- .../fbind_c_derived_layout_f90.pyi | 6 +- .../contracts/fclasses_f90/fclasses_f90.pyi | 4 +- .../fconstructors_f90/fconstructors_f90.pyi | 4 +- .../fderived_boundary_f90.pyi | 6 +- .../finheritance_f90/finheritance_f90.pyi | 6 +- ...scalar_derived_actual_dummy_matrix_f90.pyi | 2 +- .../fderived_scalar_descriptor_fields.f90 | 18 ++ .../derived_types_direct_bind_c_f90.pyi | 4 +- .../derived_types_mixed_bind_c_f90.pyi | 4 +- .../test_derived_runtime_mechanisms.py | 36 ++- .../test_scalar_actual_dummy_matrix.py | 8 + .../contracts/fenums_f90/fenums_f90.pyi | 2 +- .../foperators_f90/foperators_f90.pyi | 6 +- .../foverloads_f90/foverloads_f90.pyi | 4 +- .../combined_modules/shared_types.pyi | 2 +- .../general/expected/derived_type.json | 4 +- .../expected/derived_types_and_methods.json | 8 +- .../general/expected/modern_pyi_example.json | 12 +- .../scope_name_reuse_combinations.json | 4 +- .../contracts/fnaming_f90/fnaming_f90.pyi | 4 +- .../pipeline/fixtures/modern_math_physics.pyi | 4 +- .../pipeline/test_types_and_declarations.py | 2 +- .../fmodule_vars_f90/fmodule_vars_f90.pyi | 6 +- .../test_module_variables_and_state.py | 5 +- .../contracts/foptional_f90/foptional_f90.pyi | 2 +- .../end_to_end/test_scalar_pointers.py | 9 +- 47 files changed, 708 insertions(+), 299 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e64112f8..281a95d0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,21 @@ release tags add a leading `v` to the package version. ## Unreleased +- **Breaking:** scalar fields of a Fortran derived type read as live rank-zero + NumPy views of the object's storage, as module variables do. A numeric or + logical field returns a writable `T[()]` view instead of a NumPy scalar, and + a fixed-length character field returns a writable fixed-width bytes view + instead of a `str`; writing through the view or assigning the attribute + updates the object. Generated contracts spell these fields `T[()]` and + `String[n][()]`; an edited contract that keeps plain `T` or `String[n]` + still reads a copied value. - Scalar `allocatable` and `pointer` fields of a derived type, numeric, logical, complex, or character, are now wrapped like the matching module - variables: reading one returns a live read-only rank-zero NumPy view or - `None`, and assigning to it allocates an allocatable (resizing a - deferred-length character) or writes a pointer's current target. The view - keeps its parent object alive. These fields are not keywords of the default - constructor, in the built class and in the generated `.pyi` alike. Such a - type previously failed to build. + variables: reading one returns a live rank-zero NumPy view or `None`, and + assigning to it allocates an allocatable (resizing a deferred-length + character) or writes a pointer's current target. These fields are not + keywords of the default constructor, in the built class and in the + generated `.pyi` alike. Such a type previously failed to build. - A derived object passed through a `pointer` dummy now reports a field it cannot reach as a policy diagnostic, as an `allocatable` dummy already did, instead of failing during wrapper planning. @@ -134,10 +141,12 @@ release tags add a leading `v` to the package version. dummies wider than one byte use integer storage of their own width, as logical arrays do, so default-logical `intent(inout)` updates reach Python. - Omitting an optional `intent(inout)` scalar argument returns `None` for it. -- Scalar allocatable and pointer module variables return live read-only - rank-zero NumPy views, or `None` when storage is absent. Assigning to the - attribute allocates an allocatable (resizing a deferred-length character) or - writes a pointer's current target. +- Scalar allocatable and pointer module variables return live writable + rank-zero NumPy views of their current storage, or `None` when storage is + absent. A view is valid until native code reallocates, deallocates, or + reassociates that storage. Assigning to the attribute allocates an + allocatable (resizing a deferred-length character) or writes a pointer's + current target. - A separate module-level `PARAMETER` statement types an undeclared name by the module's `IMPLICIT` rules and is rejected under `implicit none`. - A derived type a module reaches through another module's re-export is diff --git a/docs/index.md b/docs/index.md index ee2929c21..a962a6d5a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -192,8 +192,8 @@ The generated `points.pyi` is: from prik.contracts import Addr, Arg, Float64, native_call class Point: - x: Float64 = 0.0 - y: Float64 = 0.0 + x: Float64[()] = 0.0 + y: Float64[()] = 0.0 def __init__(self, *, x: Float64 = 0.0, y: Float64 = 0.0) -> None: ... @@ -221,8 +221,8 @@ The edited `points.pyi` is: from prik.contracts import Addr, Arg, Float64, Pass, bind, native_call class Point: - x: Float64 = 0.0 - y: Float64 = 0.0 + x: Float64[()] = 0.0 + y: Float64[()] = 0.0 def __init__(self, *, x: Float64 = 0.0, y: Float64 = 0.0) -> None: ... diff --git a/docs/user/guide/allocatables.md b/docs/user/guide/allocatables.md index 26f1b2612..882d467ae 100644 --- a/docs/user/guide/allocatables.md +++ b/docs/user/guide/allocatables.md @@ -18,9 +18,8 @@ and an array handle gives Python access to that descriptor. - Scalar allocatable dummies and results appear as values or `None`. Reading a scalar allocatable module variable or derived-type field returns a live - read-only rank-zero NumPy view or `None`; assigning to it allocates when - needed. Array allocatables use - `Allocatable[T[...]]` handles. + rank-zero NumPy view or `None`; assigning to it allocates when needed. Array + allocatables use `Allocatable[T[...]]` handles. - An array handle exposes allocation state and descriptor operations; it is not itself a NumPy array. - `allocated` reports whether storage exists; `to_numpy()` returns a live view @@ -86,13 +85,14 @@ The annotation supplies the element dtype and rank. The handle creates its native storage when first passed to a matching writable argument. It stays the same Python object after the call. `Allocatable[Float64]()` is not supported. Reading a scalar module variable or -derived-type field declared `Allocatable[Float64]` returns a live read-only rank-zero `float64` -array when allocated, or `None` otherwise. Assign to the attribute to change -the value: `module.scale = np.float64(2.0)` allocates the variable when it is +derived-type field declared `Allocatable[Float64]` returns a live rank-zero +`float64` array when allocated, or `None` otherwise. Write through the view to +change the current value, or assign to the attribute: +`module.scale = np.float64(2.0)` allocates the variable when it is unallocated, and a deferred-length character takes the width of the assigned `str`. Read the attribute again after reallocation; an older view may refer to storage that is no longer valid. An allocated empty deferred-length character -reads as `b""`. +reads as a detached `b""`. A returned or attribute array handle remains present even when its descriptor is unallocated. Reading the Python attribute diff --git a/docs/user/guide/pointers.md b/docs/user/guide/pointers.md index 3cefe2bcf..d02154480 100644 --- a/docs/user/guide/pointers.md +++ b/docs/user/guide/pointers.md @@ -19,8 +19,9 @@ shape, and strides. It does not by itself say who owns that target. - A pointer descriptor refers to target storage; it does not own that storage by default. - Scalar pointer dummies and results appear as values or `None`. Reading a - scalar pointer module variable or derived-type field returns a live read-only - rank-zero NumPy view or `None`; assigning to it writes the current target. Array pointers use live + scalar pointer module variable or derived-type field returns a live + rank-zero NumPy view of its target or `None`; writing through the view or + assigning to it writes the current target. Array pointers use live `Pointer[T[...]]` handles. - `associated` describes association, not ownership or target lifetime. - NumPy arrays returned by `to_numpy()` are live views, not copies. @@ -78,9 +79,10 @@ The annotation supplies the element dtype and rank. The handle creates its native storage when first passed to a matching writable argument. It stays the same Python object after the call. `Pointer[Float64]()` is not supported. Reading a scalar module variable or -derived-type field declared `Pointer[Float64]` returns a live read-only rank-zero `float64` array -when associated, or `None` otherwise. Assigning to the attribute copies the -value into the current target; it raises `ValueError` when the pointer is not +derived-type field declared `Pointer[Float64]` returns a live rank-zero +`float64` view of its target when associated, or `None` otherwise. Write +through the view, or assign to the attribute to copy the value into the +current target; it raises `ValueError` when the pointer is not associated, and a character value must encode to the target's width. Assignment never reassociates the pointer. Read the attribute again after reassociation; an older view may refer to storage that is no longer valid. diff --git a/docs/user/guide/wrapping-derived-types.md b/docs/user/guide/wrapping-derived-types.md index e698a9928..fd2334187 100644 --- a/docs/user/guide/wrapping-derived-types.md +++ b/docs/user/guide/wrapping-derived-types.md @@ -101,8 +101,8 @@ class Point: y: Float64 = 0.0 ) -> None: ... - x: Float64 = 0.0 - y: Float64 = 0.0 + x: Float64[()] = 0.0 + y: Float64[()] = 0.0 class Holder: def __init__(self) -> None: ... @@ -159,7 +159,7 @@ print(item.x, item.y) # 4.0 6.0 made = points.make_point(np.float64(8.0), np.float64(9.0)) # Nested component -container = points.holder() +container = points.Holder() points.set_origin(container, made) container.origin.x = np.float64(12.0) print(container.origin.x) # 12.0 @@ -215,12 +215,15 @@ print(points.Point.__init__.__doc__) - **Missing intent**: A dummy without `intent` follows the same conservative in-place rule as `intent(inout)`. - **Fields**: Public scalar numeric, logical, complex, and character fields - become Python attributes. A scalar `allocatable` or `pointer` field reads as - a live read-only rank-zero NumPy view, or `None` when it is unallocated or - disassociated. Assigning to it allocates an allocatable field or writes a - pointer field's current target, as for - [module variables](allocatables.md#key-concepts). The view keeps its parent - object alive; read the field again after its storage changes. + read as live rank-zero NumPy views of the object's storage, as module + variables do. A numeric or logical field is a writable `T[()]` view, and a + fixed-length character field is a writable fixed-width bytes view: writing + through the view or assigning the attribute updates the object. A scalar + `allocatable` or `pointer` field reads as a view of its current storage, or + `None` when it is unallocated or disassociated; assigning to it allocates an + allocatable field or writes a pointer field's current target. Every view keeps its + parent object alive; read an `allocatable` or `pointer` field again after + its storage changes. - **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 @@ -298,8 +301,8 @@ In this mapping, `@bind` selects the native initializer, from prik.contracts import Addr, Arg, Float64, Pass, bind, native_call class Point: - x: Float64 - y: Float64 + x: Float64[()] + y: Float64[()] @bind("initialize_point") @native_call([Pass(), Addr(Arg(0)), Addr(Arg(1))]) diff --git a/docs/user/guide/wrapping-modules.md b/docs/user/guide/wrapping-modules.md index abf6578cc..ada10854d 100644 --- a/docs/user/guide/wrapping-modules.md +++ b/docs/user/guide/wrapping-modules.md @@ -140,9 +140,9 @@ Mutable fixed-storage scalar module variables expose live rank-zero NumPy views. Numeric and logical scalars use `T[()]`; fixed-length character scalars use `String[n][()]` with raw bytes storage. Pass the view itself when a native dummy needs its original storage. `PARAMETER` declarations remain constants. -- Scalar allocatable and pointer module variables return a live read-only - rank-zero view or `None` on each read. Assign to the attribute to change the - value, and read it again after storage changes. +- Scalar allocatable and pointer module variables return a live rank-zero view + or `None` on each read. Write through the view or assign to the attribute to + change the value, and read it again after storage changes. - Allocatable module arrays use the `Allocatable[T[...]]` API. - Allocation, lifetime, NumPy views, and mutation rules are covered in the storage and objects section. diff --git a/docs/user/language-support/feature-matrix.md b/docs/user/language-support/feature-matrix.md index 9f55976cb..f7208af23 100644 --- a/docs/user/language-support/feature-matrix.md +++ b/docs/user/language-support/feature-matrix.md @@ -59,15 +59,15 @@ where they apply. | Defined operators and assignment overloads | Supported | [Defined operators](../guide/wrapping-derived-types.md#defined-operators) | [Defined operator tests](../../../tests/fortran/generic_interfaces/end_to_end/test_defined_operators.py) | Supported operators are those covered by the wrapper guide and runtime tests. | | Output arguments and multiple results | Supported | [Subroutine projection](../guide/wrapping-subroutines.md) | [Calls and results tests](../../../tests/fortran/infrastructure/semantic_pyi/contracts/calls_and_results/end_to_end/test_edited_call_surfaces.py), [function result tests](../../../tests/fortran/functions/end_to_end/test_documented_function_journeys.py) | Tuple ordering and caller-provided array behavior follow the wrapper guide. | | Optional arguments | Supported | [Optional arguments](../guide/optional-arguments.md) | [Optional argument tests](../../../tests/fortran/optional_arguments/end_to_end/test_optional_runtime.py) | Unsupported optional combinations fail during wrapper planning. | -| Allocatable array handles, descriptor arguments, and owned results | Supported | [Allocatables](../guide/allocatables.md) | [Allocatable runtime tests](../../../tests/fortran/allocatables/end_to_end/test_allocatable_handles.py), [scalar-derived matrix tests](../../../tests/fortran/derived_types/end_to_end/test_scalar_actual_dummy_matrix.py) | Module and field handles borrow their owner; returned and caller-created handles own their storage. Numeric and character arrays are supported. Scalar allocatable module variables and fields read as live read-only rank-zero views or `None`, and assigning allocates them. | -| Pointer scalar projections and array handles | Partially supported | [Pointers](../guide/pointers.md) | [Pointer handle tests](../../../tests/fortran/pointers/end_to_end/test_pointer_handles.py), [pointer policy tests](../../../tests/fortran/pointers/policy/test_pointer_ownership_policy.py), [scalar-derived matrix tests](../../../tests/fortran/derived_types/end_to_end/test_scalar_actual_dummy_matrix.py) | Descriptor arguments, module and field handles, strided views, and returned array handles are supported. Scalar pointer module variables and fields read as live read-only rank-zero views or `None`, and assigning writes the current target. Deferred-width character views require contiguous storage and a compatible compiler. | +| Allocatable array handles, descriptor arguments, and owned results | Supported | [Allocatables](../guide/allocatables.md) | [Allocatable runtime tests](../../../tests/fortran/allocatables/end_to_end/test_allocatable_handles.py), [scalar-derived matrix tests](../../../tests/fortran/derived_types/end_to_end/test_scalar_actual_dummy_matrix.py) | Module and field handles borrow their owner; returned and caller-created handles own their storage. Numeric and character arrays are supported. Scalar allocatable module variables and fields read as live rank-zero views or `None`, and assigning allocates them. | +| Pointer scalar projections and array handles | Partially supported | [Pointers](../guide/pointers.md) | [Pointer handle tests](../../../tests/fortran/pointers/end_to_end/test_pointer_handles.py), [pointer policy tests](../../../tests/fortran/pointers/policy/test_pointer_ownership_policy.py), [scalar-derived matrix tests](../../../tests/fortran/derived_types/end_to_end/test_scalar_actual_dummy_matrix.py) | Descriptor arguments, module and field handles, strided views, and returned array handles are supported. Scalar pointer module variables and fields read as live rank-zero views or `None`, and assigning writes the current target. Deferred-width character views require contiguous storage and a compatible compiler. | | Array-valued function results | Supported | [Array results](../guide/arrays.md#mutation-and-results) | [Array result tests](../../../tests/fortran/arrays/end_to_end/test_array_results.py) | Ownership and dtype/shape behavior are limited to documented array result forms. | | NumPy array argument contracts | Supported | [Arrays](../guide/arrays.md) | [Array contract tests](../../../tests/fortran/arrays/end_to_end/test_array_contract_validation.py), [multidimensional tests](../../../tests/fortran/arrays/end_to_end/test_layout_and_strided_arrays.py) | Wrong dtype, rank, shape, contiguity, alignment, or mutability is rejected. | | Assumed-type native arguments | Partially supported | [Arrays](../guide/arrays.md#pass-values-to-type-dummies) | [Assumed-type source and edited-contract tests](../../../tests/fortran/assumed_types/end_to_end/test_assumed_type_calls.py) | `AnyNative` accepts PRIK-native NumPy and derived actuals. Descriptor dtypes have a documented subset; assumed-size is rank one, and nonblocking buffer lifetime remains caller-owned. | -| Derived-type scalar boundaries and methods | Supported | [Derived types](../guide/wrapping-derived-types.md) | [Derived boundary tests](../../../tests/fortran/derived_types/end_to_end/test_derived_boundaries.py), [method tests](../../../tests/fortran/derived_types/end_to_end/test_type_bound_methods.py) | Derived-type arrays and some polymorphic forms are not included. | +| Derived-type scalar boundaries and methods | Supported | [Derived types](../guide/wrapping-derived-types.md) | [Derived boundary tests](../../../tests/fortran/derived_types/end_to_end/test_derived_boundaries.py), [method tests](../../../tests/fortran/derived_types/end_to_end/test_type_bound_methods.py) | Scalar fields read as live rank-zero views of the object's storage. Derived-type arrays and some polymorphic forms are not included. | | Default and keyword constructors with finalizers | Supported | [Constructors and finalizers](../guide/wrapping-derived-types.md#key-concepts) | [Constructor/finalizer tests](../../../tests/fortran/derived_types/end_to_end/test_default_constructors_and_finalizers.py), [borrowed finalizer tests](../../../tests/fortran/derived_types/end_to_end/test_borrowed_components.py) | Construction commits ownership only after initialization; borrowed wrappers never run an owning finalizer. | | Generic constructor interfaces and overloaded runtime initialization | Supported | [Constructors](../guide/wrapping-derived-types.md#custom-constructor) | [Edited class surface tests](../../../tests/fortran/infrastructure/semantic_pyi/contracts/functions_and_classes/end_to_end/test_edited_class_surfaces.py), [class policy tests](../../../tests/fortran/infrastructure/semantic_pyi/contracts/functions_and_classes/policy/test_class_surface_policy.py) | Candidates require distinguishable completed Python signatures; incomplete or ambiguous sets are blocked before emission. | -| Module variables, constants, saved state, and common-block procedure state | Supported | [Wrapping modules](../guide/wrapping-modules.md) | [Module state tests](../../../tests/fortran/modules/end_to_end/test_module_variables_and_state.py), [scalar-derived matrix tests](../../../tests/fortran/derived_types/end_to_end/test_scalar_actual_dummy_matrix.py), [common-block tests](../../../tests/fortran/modules/end_to_end/test_common_blocks.py) | Common-block storage is not exported as Python variables. Rank-zero derived module objects use direct, scoped, allocation-transaction, or pointer-transaction handoff selected before lowering. Mutable fixed-storage scalars are live rank-zero NumPy views, written through the view or by assigning the attribute; a declared-length `character` scalar is a fixed-width bytes view (`String[n][()]`) that assignment fills at exactly its declared width. `allocatable` and `pointer` scalars, numeric and `character` alike, read as live read-only rank-zero views or `None`, and assigning the attribute allocates an allocatable (resizing a deferred-length `character`) or writes a pointer's current target. `character` arrays reach Python as fixed-width bytes, and arrays are mutated in place through their view or handle rather than rebound. | +| Module variables, constants, saved state, and common-block procedure state | Supported | [Wrapping modules](../guide/wrapping-modules.md) | [Module state tests](../../../tests/fortran/modules/end_to_end/test_module_variables_and_state.py), [scalar-derived matrix tests](../../../tests/fortran/derived_types/end_to_end/test_scalar_actual_dummy_matrix.py), [common-block tests](../../../tests/fortran/modules/end_to_end/test_common_blocks.py) | Common-block storage is not exported as Python variables. Rank-zero derived module objects use direct, scoped, allocation-transaction, or pointer-transaction handoff selected before lowering. Mutable fixed-storage scalars are live rank-zero NumPy views, written through the view or by assigning the attribute; a declared-length `character` scalar is a fixed-width bytes view (`String[n][()]`) that assignment fills at exactly its declared width. `allocatable` and `pointer` scalars, numeric and `character` alike, read as live rank-zero views or `None`, and assigning the attribute allocates an allocatable (resizing a deferred-length `character`) or writes a pointer's current target. `character` arrays reach Python as fixed-width bytes, and arrays are mutated in place through their view or handle rather than rebound. | | Fortran enum constants | Supported | [Enumerations](../guide/enumerations.md) | [Enum runtime tests](../../../tests/fortran/enumerations/end_to_end/test_enum_runtime.py), [enum semantic tests](../../../tests/fortran/enumerations/semantics/test_enum_semantics.py), [enum diagnostics](../../../tests/fortran/enumerations/parsing/test_enum_diagnostics.py) | No Python `Enum` or `IntEnum` classes are generated. | | Scalar character arguments, results, and fields | Supported | [Strings](../guide/strings.md) | [Character argument tests](../../../tests/fortran/strings/end_to_end/test_character_boundaries.py), [edge-case tests](../../../tests/fortran/strings/end_to_end/test_character_edge_cases.py) | Character arrays use fixed-width NumPy bytes dtype. Scalar `character` `allocatable` and `pointer` values are supported for `intent(in)`, `intent(out)`, `intent(inout)`, and function results, at deferred (`len=:`) and declared (`len=n`) length; a mutable dummy returns the value the procedure left behind, or `None`. PRIK frees the target it allocated for the call while it can still prove that identity, but never a target the procedure reassociated or the library owns; a procedure that returns a fresh allocation each call leaks unless it frees its own. | | Character arrays and caller-supplied deferred-length character storage | Supported | [Strings](../guide/strings.md) | [Character edge tests](../../../tests/fortran/strings/end_to_end/test_character_edge_cases.py) | Character arrays use fixed-width NumPy bytes dtype, whose width each accessor reports from the Fortran declaration; Unicode/object arrays are unsupported. Scalar `character` `allocatable` and `pointer` values work for every intent and as function results. A mutable `pointer` dummy that the native procedure reassociates without deallocating orphans the target the adapter allocated for that call. | diff --git a/docs/user/reference/pyi-format.md b/docs/user/reference/pyi-format.md index a633df633..39ea8c555 100644 --- a/docs/user/reference/pyi-format.md +++ b/docs/user/reference/pyi-format.md @@ -400,14 +400,14 @@ counter: Int32[()] scale: Float64[()] ``` -Mutable Fortran module variables expose their native storage. Fixed-storage -numeric and logical scalars use live rank-zero `T[()]` NumPy views; fixed-length -character scalars use live rank-zero `String[n][()]` bytes views. `PARAMETER` -declarations use `Final[...]` values. Scalar allocatable and pointer module -variables return a live rank-zero view or `None` on each read; array descriptors -use handles. An edited plain `T` module declaration requests a scalar value -getter. A literal default on supported mutable scalar state is an import-time -native initializer. +Mutable Fortran module variables and derived-type fields expose their native +storage. Fixed-storage numeric and logical scalars use live rank-zero `T[()]` +NumPy views; fixed-length character scalars use live rank-zero `String[n][()]` +bytes views. `PARAMETER` declarations use `Final[...]` values. Scalar +allocatable and pointer module variables and fields return a live rank-zero +view of their current storage or `None` on each read; array descriptors use handles. An edited +plain `T` declaration requests a scalar value getter. A literal default on +supported mutable scalar state is an import-time native initializer. C global declarations can be represented for inspection, but current C wrapper builds reject native global state. C functions remain the supported runtime @@ -437,14 +437,16 @@ in generated files, although source order is accepted: from prik.contracts import Float64, Int32 class particle: - identifier: Int32 - mass: Float64 + identifier: Int32[()] + mass: Float64[()] def reset(self) -> None: ... ``` An untyped leading `self` is the only untyped callable parameter. Ordinary -fields use the same type and storage syntax as variables. +fields use the same type and storage syntax as variables: a stored `T[()]` or +`String[n][()]` field reads as a live writable rank-zero view of the object's +storage, and an edited plain `T` or `String[n]` field reads as a copied value. Fortran `bind(C)` types retain that fact without exposing their layout: @@ -453,8 +455,8 @@ from prik.contracts import Float64, native_abi @native_abi("c") class point: - x: Float64 - y: Float64 + x: Float64[()] + y: Float64[()] ``` `@native_abi("c")` is Fortran-specific. A C-native contract already has a C @@ -485,8 +487,8 @@ class state: scale: Float64 = 1.0, ) -> None: ... - identifier: Int32 = 0 - scale: Float64 = 1.0 + identifier: Int32[()] = 0 + scale: Float64[()] = 1.0 ``` Removing that declaration removes public construction. A constructor bound to @@ -811,7 +813,7 @@ stores or passes it: | Contract | Meaning | Languages | | --- | --- | --- | | `T` | Scalar Python value or wrapped object. | Shared. | -| `T[()]` | Rank-zero NumPy storage; supported numeric module variables expose live native storage. | Shared. | +| `T[()]` | Rank-zero NumPy storage; supported module variables and fields expose live native storage. | Shared. | | `T[n]` | Rank-one array with extent `n`. | Shared. | | `T[:]` | Rank-one array with runtime extent. | Shared. | | `T[:, :]` | Rank-two array with runtime extents. | Shared. | diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 30ffc309d..8fc075667 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -222,7 +222,7 @@ class _COverloadDispatch: ModuleGetterAction.DIRECT_VALUE: "Builds a Python scalar from the current native value.", ModuleGetterAction.NATIVE_SCALAR_VIEW: "Wraps live native scalar storage in a rank-zero NumPy view.", ModuleGetterAction.NATIVE_CHARACTER_VIEW: "Wraps live native character bytes in a rank-zero NumPy view.", - ModuleGetterAction.NATIVE_NULLABLE_SCALAR_VIEW: "Lends the current native scalar storage read-only, or returns None.", + ModuleGetterAction.NATIVE_NULLABLE_SCALAR_VIEW: "Lends the current native scalar storage, or returns None.", ModuleGetterAction.CHARACTER_VALUE: "Decodes the fixed-width native characters into a Python str.", ModuleGetterAction.NULLABLE_SNAPSHOT: "Returns a detached copy, or None when the native value holds nothing.", ModuleGetterAction.BORROWED_ARRAY_VIEW: "Wraps the native storage in a live NumPy array without copying.", @@ -2677,24 +2677,38 @@ def _allocatable_holder_field_functions( field: DerivedFieldPlan, ) -> tuple[CFunction, ...]: """Expose scalar holder fields through holder-checked private methods.""" - if field.access is not DerivedFieldAccessMechanism.SCALAR_VALUE: - raise ValueError(f"Unsupported allocatable-holder field for {field.owner_path!r}: {field.access.value}") scalar = PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name) owner_nodes = self._allocatable_holder_owner_nodes(derived.backend_symbol, setter=False) - getter = self._derived_private_method( - self._allocatable_holder_field_method_name(derived, field, "get"), - ( - *owner_nodes, - CDeclaration( - "value", - scalar.c_spelling, - CodeExpression( - self._allocatable_holder_field_bridge_name(derived, field, "get") + "(owner_address)" + if field.access is DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW: + getter = self._derived_private_method( + self._allocatable_holder_field_method_name(derived, field, "get"), + ( + *owner_nodes, + *self._scalar_storage_view_nodes( + f"{self._allocatable_holder_field_bridge_name(derived, field, 'get')}(owner_address)", + field.semantic_type_name, + None, + "owner_obj", ), ), - CReturn(CodeExpression(self._scalar_result_expression(scalar, "&value"))), - ), - ) + ) + elif field.access is not DerivedFieldAccessMechanism.SCALAR_VALUE: + raise ValueError(f"Unsupported allocatable-holder field for {field.owner_path!r}: {field.access.value}") + else: + getter = self._derived_private_method( + self._allocatable_holder_field_method_name(derived, field, "get"), + ( + *owner_nodes, + CDeclaration( + "value", + scalar.c_spelling, + CodeExpression( + self._allocatable_holder_field_bridge_name(derived, field, "get") + "(owner_address)" + ), + ), + CReturn(CodeExpression(self._scalar_result_expression(scalar, "&value"))), + ), + ) if field.setter_action is not SetterAction.WRITE_THROUGH: return (getter,) setter = self._derived_private_method( @@ -2770,21 +2784,37 @@ def _pointer_holder_field_functions( field: DerivedFieldPlan, ) -> tuple[CFunction, ...]: """Build pointer holder field functions from the supplied completed binding records; emitted nodes only project completed binding actions.""" - if field.access is not DerivedFieldAccessMechanism.SCALAR_VALUE: - raise ValueError(f"Unsupported pointer-holder field for {field.owner_path!r}: {field.access.value}") scalar = PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name) - getter = self._derived_private_method( - self._pointer_holder_field_method_name(derived, field, "get"), - ( - *self._pointer_holder_owner_nodes(derived.backend_symbol, setter=False), - CDeclaration( - "value", - scalar.c_spelling, - CodeExpression(self._pointer_holder_field_bridge_name(derived, field, "get") + "(owner_address)"), + if field.access is DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW: + getter = self._derived_private_method( + self._pointer_holder_field_method_name(derived, field, "get"), + ( + *self._pointer_holder_owner_nodes(derived.backend_symbol, setter=False), + *self._scalar_storage_view_nodes( + f"{self._pointer_holder_field_bridge_name(derived, field, 'get')}(owner_address)", + field.semantic_type_name, + None, + "owner_obj", + ), ), - CReturn(CodeExpression(self._scalar_result_expression(scalar, "&value"))), - ), - ) + ) + elif field.access is not DerivedFieldAccessMechanism.SCALAR_VALUE: + raise ValueError(f"Unsupported pointer-holder field for {field.owner_path!r}: {field.access.value}") + else: + getter = self._derived_private_method( + self._pointer_holder_field_method_name(derived, field, "get"), + ( + *self._pointer_holder_owner_nodes(derived.backend_symbol, setter=False), + CDeclaration( + "value", + scalar.c_spelling, + CodeExpression( + self._pointer_holder_field_bridge_name(derived, field, "get") + "(owner_address)" + ), + ), + CReturn(CodeExpression(self._scalar_result_expression(scalar, "&value"))), + ), + ) if field.setter_action is not SetterAction.WRITE_THROUGH: return (getter,) setter = self._derived_private_method( @@ -2864,6 +2894,7 @@ def _direct_field_functions( DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE: self._direct_handle_field_functions, DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR: self._direct_array_field_functions, DerivedFieldAccessMechanism.SCALAR_VALUE: self._direct_scalar_field_functions, + DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW: self._direct_scalar_storage_field_functions, DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW: self._direct_scalar_descriptor_field_functions, DerivedFieldAccessMechanism.NESTED_OBJECT: self._direct_nested_field_functions, } @@ -2883,6 +2914,7 @@ def _module_member_functions( DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE: self._module_handle_member_functions, DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR: self._module_array_member_functions, DerivedFieldAccessMechanism.SCALAR_VALUE: self._module_scalar_member_functions, + DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW: self._module_scalar_storage_member_functions, DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW: self._module_scalar_descriptor_member_functions, DerivedFieldAccessMechanism.NESTED_OBJECT: self._module_nested_member_functions, } @@ -2916,6 +2948,50 @@ def _direct_scalar_field_functions(self, derived, field) -> tuple[CFunction, ... self._direct_scalar_field_setter(derived, field), ) + def _direct_scalar_storage_field_functions(self, derived, field) -> tuple[CFunction, ...]: + """Lend a stored scalar field's storage and copy assigned values into it.""" + getter = self._derived_private_method( + self._derived_field_method_name(derived, field, "get"), + ( + *self._derived_owner_address_nodes(derived), + *self._scalar_storage_view_nodes( + f"{self._derived_field_bridge_name(derived, field, 'get')}(owner_address)", + field.semantic_type_name, + field.character_length if field.string_element else None, + "owner_obj", + ), + ), + ) + setter = ( + self._direct_string_field_setter(derived, field) + if field.string_element + else self._direct_scalar_field_setter(derived, field) + ) + return self._optional_field_functions(getter, setter) + + def _module_scalar_storage_member_functions(self, variable, member) -> tuple[CFunction, ...]: + """Lend a module object's stored scalar member and copy assigned values into it.""" + field = member.field + getter = self._derived_private_method( + self._module_member_method_name(variable, member, "get"), + ( + CDeclaration("owner_obj", "PyObject *"), + CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &owner_obj)) return NULL')), + *self._scalar_storage_view_nodes( + f"{self._module_member_bridge_name(variable, member, 'get')}()", + field.semantic_type_name, + field.character_length if field.string_element else None, + "owner_obj", + ), + ), + ) + setter = ( + self._module_string_member_setter(variable, member) + if field.string_element + else self._module_scalar_member_setter(variable, member) + ) + return self._optional_field_functions(getter, setter) + def _direct_scalar_descriptor_field_functions(self, derived, field) -> tuple[CFunction, ...]: """Lend a scalar allocatable or pointer field's storage and assign through it.""" return self._scalar_descriptor_field_functions( @@ -6238,54 +6314,66 @@ def _lower_module_getter_direct_value(self, plan: ModuleVariablePlan) -> tuple[C def _lower_module_getter_native_scalar_view(self, plan: ModuleVariablePlan) -> tuple[CFunction, ...]: """Expose live scalar module storage as a rank-zero NumPy view.""" - scalar = PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name) - owner = self._module_native_array_owner_name(plan) return ( CFunction( self._module_getter_name(plan), "PyObject *", storage="static", - body=( - CDeclaration("data", "void *", CodeExpression(f"{self._module_bridge_getter_name(plan)}()")), - CDeclaration( - "result", - "PyObject *", - CodeExpression(f"PyArray_SimpleNewFromData(0, NULL, {scalar.array_numpy_type}, data)"), - ), - *self._ordinary_array_field_owner_nodes("result", owner), + body=self._scalar_storage_view_nodes( + f"{self._module_bridge_getter_name(plan)}()", + plan.semantic_type_name, + None, + self._module_native_array_owner_name(plan), ), ), ) def _lower_module_getter_native_character_view(self, plan: ModuleVariablePlan) -> tuple[CFunction, ...]: """Expose fixed character storage as a live rank-zero NumPy bytes view.""" - length = self._module_character_length(plan) - owner = self._module_native_array_owner_name(plan) return ( CFunction( self._module_getter_name(plan), "PyObject *", storage="static", - body=( - CDeclaration("data", "void *", CodeExpression(f"{self._module_bridge_getter_name(plan)}()")), - CDeclaration( - "result", - "PyObject *", - CodeExpression( - f"PyArray_New(&PyArray_Type, 0, NULL, NPY_STRING, NULL, data, {length}, " - "NPY_ARRAY_ALIGNED | NPY_ARRAY_WRITEABLE, NULL)" - ), - ), - *self._ordinary_array_field_owner_nodes("result", owner), + body=self._scalar_storage_view_nodes( + f"{self._module_bridge_getter_name(plan)}()", + plan.semantic_type_name, + self._module_character_length(plan), + self._module_native_array_owner_name(plan), ), ), ) + def _scalar_storage_view_nodes( + self, + address_call: str, + semantic_type_name: str, + character_length: int | None, + owner: str, + ) -> tuple: + """Lend fixed scalar storage as a live writable rank-zero view kept alive by ``owner``. + + A character lends its declared width as fixed-width bytes. + """ + if character_length is not None: + view = ( + f"PyArray_New(&PyArray_Type, 0, NULL, NPY_STRING, NULL, data, {character_length}, " + "NPY_ARRAY_ALIGNED | NPY_ARRAY_WRITEABLE, NULL)" + ) + else: + scalar = PrimitiveScalarTypeRegistry.type_for(semantic_type_name) + view = f"PyArray_SimpleNewFromData(0, NULL, {scalar.array_numpy_type}, data)" + return ( + CDeclaration("data", "void *", CodeExpression(address_call)), + CDeclaration("result", "PyObject *", CodeExpression(view)), + *self._ordinary_array_field_owner_nodes("result", owner), + ) + def _lower_module_getter_native_nullable_scalar_view(self, plan: ModuleVariablePlan) -> tuple[CFunction, ...]: - """Lend the currently present scalar storage as one read-only rank-zero view. + """Lend the currently present scalar storage as one writable rank-zero view. - The descriptor may be reallocated or reassociated after this read, so - Python writes through the setter rather than through the view. + The view is valid until the descriptor is reallocated or reassociated; + assigning the attribute always reaches the current storage. """ return ( CFunction( @@ -6311,10 +6399,12 @@ def _scalar_descriptor_view_nodes( semantic_type_name: str, owner: str, ) -> tuple: - """Lend one scalar descriptor's current storage read-only, or return ``None``. + """Lend one scalar descriptor's current storage as a writable view, or return ``None``. The view keeps ``owner`` alive, which holds the descriptor: a module - variable's module or a field's parent object. + variable's module or a field's parent object. Like an array view, it + is valid until native code reallocates, deallocates, or reassociates + that storage. """ if character: numpy_type, width = "NPY_STRING", "(int)length" @@ -6333,7 +6423,8 @@ def _scalar_descriptor_view_nodes( "result", "PyObject *", CodeExpression( - f"PyArray_New(&PyArray_Type, 0, NULL, {numpy_type}, NULL, data, {width}, NPY_ARRAY_ALIGNED, NULL)" + f"PyArray_New(&PyArray_Type, 0, NULL, {numpy_type}, NULL, data, {width}, " + "NPY_ARRAY_ALIGNED | NPY_ARRAY_WRITEABLE, NULL)" ), ), *self._ordinary_array_field_owner_nodes("result", owner), diff --git a/prik/codegen/docstrings.py b/prik/codegen/docstrings.py index e1a96ea1c..e019fb6b4 100644 --- a/prik/codegen/docstrings.py +++ b/prik/codegen/docstrings.py @@ -86,7 +86,7 @@ "Live view of the module's fixed-width character bytes; writing through it updates the module." ), ModuleGetterAction.NATIVE_NULLABLE_SCALAR_VIEW: ( - "Live read-only view of the current storage, or None when it holds none." + "Live view of the current storage, or None when it holds none; read it again after reallocation." ), } @@ -570,6 +570,8 @@ def field(self, field: DerivedFieldPlan) -> str: lines.append(" The parent wrapper retains the descriptor owner.") elif field.array is not None: lines.append(" Borrowed native view retained by the parent wrapper.") + elif field.access is DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW: + lines.append(" Live view of the object's storage; writing through it updates the object.") elif field.access is DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW: lines.append(f" {_MODULE_SCALAR_VIEW_NOTES[ModuleGetterAction.NATIVE_NULLABLE_SCALAR_VIEW]}") if field.setter_action is SetterAction.WRITE_THROUGH: diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index 52cfe1eda..a20153d6c 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -82,7 +82,7 @@ FortranTypeDefinition, FortranUse, ) -from prik.planning.planner import has_scalar_descriptor_fields, requires_address_capture +from prik.planning.planner import has_scalar_view_fields, requires_address_capture from prik.planning.models import ( ArrayHandoffPlan, ArgumentTransferPlan, @@ -3734,7 +3734,7 @@ def _module_array_address(plan: ModuleVariablePlan, native: str) -> str: def _captures_scalar_address(self, plan: ModulePlan) -> bool: """Report whether a scalar view or scalar descriptor field takes its address in C.""" - return has_scalar_descriptor_fields(plan) or any( + return has_scalar_view_fields(plan) or any( variable.storage_address is ModuleStorageAddressMechanism.CAPTURED_ADDRESS and variable.bridge.native_getter_action in { @@ -7681,23 +7681,32 @@ def _allocatable_holder_field_procedures( field: DerivedFieldPlan, ) -> tuple[FortranFunction, ...]: """Lower scalar fields through the typed holder selected by policy.""" - if field.access is not DerivedFieldAccessMechanism.SCALAR_VALUE: - raise ValueError(f"Unsupported allocatable-holder field for {field.owner_path!r}: {field.access.value}") scalar = PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name) holder_type = self._allocatable_holder_type_name(derived.backend_symbol) getter_name = self._allocatable_holder_field_bridge_name(derived, field, "get") - getter = FortranFunction( - name=getter_name, - parameters=(FortranParameter("owner_address", "type(c_ptr)", ("value",)),), - result_name="result", - result_type=scalar.fortran_spelling, - bind_name=getter_name, - declarations=(FortranDeclaration("owner", f"type({holder_type})", ("pointer",)),), - body=( - self._derived_owner_association(), - FortranAssignment("result", CodeExpression(f"owner%value%{field.native_name}")), - ), - ) + if field.access is DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW: + getter = self._scalar_storage_getter( + getter_name, + f"owner%value%{field.native_name}", + parameters=(FortranParameter("owner_address", "type(c_ptr)", ("value",)),), + declarations=(FortranDeclaration("owner", f"type({holder_type})", ("pointer",)),), + prologue=(self._derived_owner_association(),), + ) + elif field.access is not DerivedFieldAccessMechanism.SCALAR_VALUE: + raise ValueError(f"Unsupported allocatable-holder field for {field.owner_path!r}: {field.access.value}") + else: + getter = FortranFunction( + name=getter_name, + parameters=(FortranParameter("owner_address", "type(c_ptr)", ("value",)),), + result_name="result", + result_type=scalar.fortran_spelling, + bind_name=getter_name, + declarations=(FortranDeclaration("owner", f"type({holder_type})", ("pointer",)),), + body=( + self._derived_owner_association(), + FortranAssignment("result", CodeExpression(f"owner%value%{field.native_name}")), + ), + ) if field.setter_action is not SetterAction.WRITE_THROUGH: return (getter,) setter_name = self._allocatable_holder_field_bridge_name(derived, field, "set") @@ -7723,23 +7732,32 @@ def _pointer_holder_field_procedures( field: DerivedFieldPlan, ) -> tuple[FortranFunction, ...]: """Lower scalar fields through a pointer holder without owning its target.""" - if field.access is not DerivedFieldAccessMechanism.SCALAR_VALUE: - raise ValueError(f"Unsupported pointer-holder field for {field.owner_path!r}: {field.access.value}") scalar = PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name) holder_type = self._pointer_holder_type_name(derived.backend_symbol) getter_name = self._pointer_holder_field_bridge_name(derived, field, "get") - getter = FortranFunction( - name=getter_name, - parameters=(FortranParameter("owner_address", "type(c_ptr)", ("value",)),), - result_name="result", - result_type=scalar.fortran_spelling, - bind_name=getter_name, - declarations=(FortranDeclaration("owner", f"type({holder_type})", ("pointer",)),), - body=( - self._derived_owner_association(), - FortranAssignment("result", CodeExpression(f"owner%value%{field.native_name}")), - ), - ) + if field.access is DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW: + getter = self._scalar_storage_getter( + getter_name, + f"owner%value%{field.native_name}", + parameters=(FortranParameter("owner_address", "type(c_ptr)", ("value",)),), + declarations=(FortranDeclaration("owner", f"type({holder_type})", ("pointer",)),), + prologue=(self._derived_owner_association(),), + ) + elif field.access is not DerivedFieldAccessMechanism.SCALAR_VALUE: + raise ValueError(f"Unsupported pointer-holder field for {field.owner_path!r}: {field.access.value}") + else: + getter = FortranFunction( + name=getter_name, + parameters=(FortranParameter("owner_address", "type(c_ptr)", ("value",)),), + result_name="result", + result_type=scalar.fortran_spelling, + bind_name=getter_name, + declarations=(FortranDeclaration("owner", f"type({holder_type})", ("pointer",)),), + body=( + self._derived_owner_association(), + FortranAssignment("result", CodeExpression(f"owner%value%{field.native_name}")), + ), + ) if field.setter_action is not SetterAction.WRITE_THROUGH: return (getter,) setter_name = self._pointer_holder_field_bridge_name(derived, field, "set") @@ -7764,72 +7782,174 @@ def _direct_field_procedures( derived: DerivedTypePlan, field: DerivedFieldPlan, ) -> tuple[FortranFunction, ...]: - """Dispatch address-backed field access by completed object kind.""" - if field.access is DerivedFieldAccessMechanism.FIXED_STRING_COPY: - getter = self._direct_string_field_getter(derived, field) - setter = self._direct_string_field_setter(derived, field) - return (getter, *((setter,) if setter is not None else ())) - if field.access is DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE: - return self._direct_native_handle_field_procedures(derived, field) - if field.access is DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW: - access = self._field_descriptor( - field, - f"owner%{field.native_name}", - parameters=(FortranParameter("owner_address", "type(c_ptr)", ("value",)),), - declarations=(self._derived_owner_declaration(derived),), - prologue=(self._derived_owner_association(),), - ) - return self._scalar_descriptor_field_procedures( - field, - access, - self._derived_field_bridge_name(derived, field, "get"), - self._derived_field_bridge_name(derived, field, "set"), - ) - if field.access is DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR: - getter = self._direct_ordinary_array_field_getter(derived, field) - setter = self._direct_ordinary_array_field_setter(derived, field) - elif field.access is DerivedFieldAccessMechanism.SCALAR_VALUE: - getter = self._direct_scalar_field_getter(derived, field) - setter = self._direct_scalar_field_setter(derived, field) - elif field.access is DerivedFieldAccessMechanism.NESTED_OBJECT: - getter = self._direct_nested_field_getter(derived, field) - setter = self._direct_nested_field_setter(derived, field) - else: - raise ValueError(f"Unsupported Fortran field lowering for {field.owner_path!r}") - return (getter, *((setter,) if setter is not None else ())) + """Dispatch address-backed field access by its completed access mechanism.""" + builders = { + DerivedFieldAccessMechanism.FIXED_STRING_COPY: self._direct_string_field_procedures, + DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE: self._direct_native_handle_field_procedures, + DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW: self._direct_scalar_storage_field_procedures, + DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW: self._direct_scalar_descriptor_field_procedures, + DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR: self._direct_ordinary_array_field_procedures, + DerivedFieldAccessMechanism.SCALAR_VALUE: self._direct_scalar_value_field_procedures, + DerivedFieldAccessMechanism.NESTED_OBJECT: self._direct_nested_field_procedures, + } + try: + return builders[field.access](derived, field) + except KeyError as error: + raise ValueError(f"Unsupported Fortran field lowering for {field.owner_path!r}") from error + + @staticmethod + def _field_accessors(getter: FortranFunction | None, setter: FortranFunction | None) -> tuple[FortranFunction, ...]: + """Return the accessors a field's completed getter and setter actions produced.""" + return tuple(procedure for procedure in (getter, setter) if procedure is not None) + + def _direct_owner(self, derived: DerivedTypePlan) -> dict: + """Return the owner parameter, declaration, and association of a direct field procedure.""" + return { + "parameters": (FortranParameter("owner_address", "type(c_ptr)", ("value",)),), + "declarations": (self._derived_owner_declaration(derived),), + "prologue": (self._derived_owner_association(),), + } + + def _direct_string_field_procedures(self, derived, field) -> tuple[FortranFunction, ...]: + """Copy a fixed character field through a byte buffer.""" + return self._field_accessors( + self._direct_string_field_getter(derived, field), + self._direct_string_field_setter(derived, field), + ) + + def _direct_scalar_storage_field_procedures(self, derived, field) -> tuple[FortranFunction, ...]: + """Lend a stored scalar field's address and copy assigned values into it.""" + getter = self._scalar_storage_getter( + self._derived_field_bridge_name(derived, field, "get"), + f"owner%{field.native_name}", + **self._direct_owner(derived), + ) + setter = ( + self._direct_string_field_setter(derived, field) + if field.string_element + else self._direct_scalar_field_setter(derived, field) + ) + return self._field_accessors(getter, setter) + + def _direct_scalar_descriptor_field_procedures(self, derived, field) -> tuple[FortranFunction, ...]: + """Lend a scalar allocatable or pointer field's current storage and assign through it.""" + return self._scalar_descriptor_field_procedures( + field, + self._field_descriptor(field, f"owner%{field.native_name}", **self._direct_owner(derived)), + self._derived_field_bridge_name(derived, field, "get"), + self._derived_field_bridge_name(derived, field, "set"), + ) + + def _direct_ordinary_array_field_procedures(self, derived, field) -> tuple[FortranFunction, ...]: + """Report a fixed array field's storage and extents.""" + return self._field_accessors( + self._direct_ordinary_array_field_getter(derived, field), + self._direct_ordinary_array_field_setter(derived, field), + ) + + def _direct_scalar_value_field_procedures(self, derived, field) -> tuple[FortranFunction, ...]: + """Copy a scalar field an edited contract declares as a plain value.""" + return self._field_accessors( + self._direct_scalar_field_getter(derived, field), + self._direct_scalar_field_setter(derived, field), + ) + + def _direct_nested_field_procedures(self, derived, field) -> tuple[FortranFunction, ...]: + """Lend a nested derived field's address.""" + return self._field_accessors( + self._direct_nested_field_getter(derived, field), + self._direct_nested_field_setter(derived, field), + ) def _module_member_procedures( self, variable: ModuleVariablePlan, member: DerivedMemberPathPlan, ) -> tuple[FortranFunction, ...]: - """Dispatch one plain-module member operation by typed field kind.""" - field = member.field - if field.access is DerivedFieldAccessMechanism.FIXED_STRING_COPY: - getter = self._module_string_member_getter(variable, member) - setter = self._module_string_member_setter(variable, member) - return (getter, *((setter,) if setter is not None else ())) - if field.access is DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE: - return self._module_native_handle_member_procedures(variable, member) - if field.access is DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW: - return self._scalar_descriptor_field_procedures( - field, - self._field_descriptor(field, self._module_member_expression(variable, member)), - self._module_member_bridge_name(variable, member, "get"), - self._module_member_bridge_name(variable, member, "set"), - ) - if field.access is DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR: - getter = self._module_ordinary_array_member_getter(variable, member) - setter = self._module_ordinary_array_member_setter(variable, member) - return (getter, *((setter,) if setter is not None else ())) - if field.access is DerivedFieldAccessMechanism.SCALAR_VALUE: - getter = self._module_scalar_member_getter(variable, member) - setter = self._module_scalar_member_setter(variable, member) - return (getter, *((setter,) if setter is not None else ())) - if field.access is DerivedFieldAccessMechanism.NESTED_OBJECT: - setter = self._module_nested_member_setter(variable, member) - return (setter,) if setter is not None else () - raise ValueError(f"Unsupported Fortran module member lowering for {field.owner_path!r}") + """Dispatch one plain-module member operation by its completed access mechanism.""" + builders = { + DerivedFieldAccessMechanism.FIXED_STRING_COPY: self._module_string_member_procedures, + DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE: self._module_native_handle_member_procedures, + DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW: self._module_scalar_storage_member_procedures, + DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW: self._module_scalar_descriptor_member_procedures, + DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR: self._module_ordinary_array_member_procedures, + DerivedFieldAccessMechanism.SCALAR_VALUE: self._module_scalar_value_member_procedures, + DerivedFieldAccessMechanism.NESTED_OBJECT: self._module_nested_member_procedures, + } + try: + return builders[member.field.access](variable, member) + except KeyError as error: + raise ValueError(f"Unsupported Fortran module member lowering for {member.field.owner_path!r}") from error + + def _module_string_member_procedures(self, variable, member) -> tuple[FortranFunction, ...]: + """Copy a module object's fixed character member through a byte buffer.""" + return self._field_accessors( + self._module_string_member_getter(variable, member), + self._module_string_member_setter(variable, member), + ) + + def _module_scalar_storage_member_procedures(self, variable, member) -> tuple[FortranFunction, ...]: + """Lend a module object's stored scalar member and copy assigned values into it.""" + getter = self._scalar_storage_getter( + self._module_member_bridge_name(variable, member, "get"), + self._module_member_expression(variable, member), + ) + setter = ( + self._module_string_member_setter(variable, member) + if member.field.string_element + else self._module_scalar_member_setter(variable, member) + ) + return self._field_accessors(getter, setter) + + def _module_scalar_descriptor_member_procedures(self, variable, member) -> tuple[FortranFunction, ...]: + """Lend a module object's scalar allocatable or pointer member and assign through it.""" + return self._scalar_descriptor_field_procedures( + member.field, + self._field_descriptor(member.field, self._module_member_expression(variable, member)), + self._module_member_bridge_name(variable, member, "get"), + self._module_member_bridge_name(variable, member, "set"), + ) + + def _module_ordinary_array_member_procedures(self, variable, member) -> tuple[FortranFunction, ...]: + """Report a module object's fixed array member storage and extents.""" + return self._field_accessors( + self._module_ordinary_array_member_getter(variable, member), + self._module_ordinary_array_member_setter(variable, member), + ) + + def _module_scalar_value_member_procedures(self, variable, member) -> tuple[FortranFunction, ...]: + """Copy a module object's scalar member an edited contract declares as a plain value.""" + return self._field_accessors( + self._module_scalar_member_getter(variable, member), + self._module_scalar_member_setter(variable, member), + ) + + def _module_nested_member_procedures(self, variable, member) -> tuple[FortranFunction, ...]: + """Replace a module object's nested derived member; reads go through the proxy.""" + return self._field_accessors(None, self._module_nested_member_setter(variable, member)) + + @staticmethod + def _scalar_storage_getter( + name: str, + native: str, + *, + parameters: tuple[FortranParameter, ...] = (), + declarations: tuple[FortranDeclaration, ...] = (), + prologue: tuple[FortranCall, ...] = (), + ) -> FortranFunction: + """Return where one stored scalar field lives, captured on the C side. + + The field need not be a target, so ``c_loc`` cannot name it. + """ + return FortranFunction( + name=name, + parameters=parameters, + result_name="result", + result_type="type(c_ptr)", + bind_name=name, + declarations=declarations, + body=(*prologue, FortranAssignment("result", CodeExpression(f"{_MODULE_SCALAR_CAPTURE_NAME}({native})"))), + ) def _scalar_descriptor_field_procedures( self, diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index 8d08d8d46..f3eacfb1c 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -177,6 +177,12 @@ def generated_files(self) -> tuple[Path, ...]: return (*self.compile_sources, *self.headers) +# Field accesses that lend a rank-zero view of the field's own storage. +_SCALAR_VIEW_FIELD_ACCESS = frozenset( + {DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW, DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW} +) + + class WrapperGenerator: """Turn one editable ``ModulePlan`` into one complete generated wrapper. @@ -881,6 +887,7 @@ def _derived_field_diagnostics(self, field) -> tuple[WrapperPlanDiagnostic, ...] DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR, DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE, DerivedFieldAccessMechanism.NESTED_OBJECT, + DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW, DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW, } else DerivedOwnerRetention.NONE @@ -916,10 +923,8 @@ def _derived_field_setter_diagnostics(self, field) -> tuple[WrapperPlanDiagnosti def _derived_field_family_diagnostics(self, field) -> tuple[WrapperPlanDiagnostic, ...]: """Dispatch field-facet consistency from its completed object kind.""" match field.object_kind: - case ObjectKind.SCALAR | ObjectKind.STRING if ( - field.access is DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW - ): - valid = field.getter_action is CodegenAction.BORROWED_VIEW and field.rank == 0 + case ObjectKind.SCALAR | ObjectKind.STRING if field.access in _SCALAR_VIEW_FIELD_ACCESS: + valid = self._valid_scalar_view_derived_field(field) case ObjectKind.SCALAR: valid = self._valid_scalar_derived_field(field) case ObjectKind.STRING: @@ -936,6 +941,22 @@ def _derived_field_family_diagnostics(self, field) -> tuple[WrapperPlanDiagnosti return () return tuple(self._native_array_handle_shape_diagnostics(field.owner_path, field.native_array_handle)) + @staticmethod + def _valid_scalar_view_derived_field(field) -> bool: + """Return whether one field lends a rank-zero view of its own storage. + + A stored character field lends its declared width, so it needs one. + """ + return ( + field.getter_action is CodegenAction.BORROWED_VIEW + and field.rank == 0 + and not ( + field.access is DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW + and field.string_element + and not field.character_length + ) + ) + @staticmethod def _valid_scalar_derived_field(field) -> bool: """Return whether one field is the completed scalar-value field variant. diff --git a/prik/planning/entrypoints.py b/prik/planning/entrypoints.py index 1dc399307..a746256aa 100644 --- a/prik/planning/entrypoints.py +++ b/prik/planning/entrypoints.py @@ -630,6 +630,8 @@ def _field_operations( owner_path = self._field_owner_path(owner, field) owner_parameter = route != "module" if route in {"allocatable", "pointer"}: + if field.access is DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW and not field.string_element: + return self._scalar_storage_field_operations(owner, field, route, owner_path, owner_parameter=True) if field.access is not DerivedFieldAccessMechanism.SCALAR_VALUE: raise ValueError(f"Unsupported {route}-holder field entrypoint for {field.owner_path!r}") return self._scalar_field_operations(owner, field, route, owner_path, owner_parameter=True) @@ -637,6 +639,8 @@ def _field_operations( return self._field_handle_operations(owner, field, route, owner_path, owner_parameter) if field.access is DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW: return self._scalar_descriptor_field_operations(owner, field, route, owner_path, owner_parameter) + if field.access is DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW: + return self._scalar_storage_field_operations(owner, field, route, owner_path, owner_parameter) if field.access is DerivedFieldAccessMechanism.FIXED_STRING_COPY: return self._string_field_operations(owner, field, route, owner_path, owner_parameter) if field.access is DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR: @@ -695,6 +699,45 @@ def _nested_module_field_operations(self, owner, field, route, owner_path): ), ) + def _scalar_storage_field_operations(self, owner, field, route, owner_path, owner_parameter): + """Plan the storage-address getter and value setter of a stored scalar field. + + The setter takes the value the way a copied field's setter does: a + number by value, or a character in a buffer of the declared width. + """ + owner_values = (self._opaque_parameter("owner", fortran_name="owner_address"),) if owner_parameter else () + operations = [ + self._operation( + owner_path, + f"field:{route}:get", + self._field_symbol(owner, field, route, "get"), + owner_values, + self._opaque_result(), + ) + ] + if field.setter_action is SetterAction.WRITE_THROUGH: + value = ( + self._value( + "value", + NativeEntrypointABIValueKind.CHARACTER, + pointer_depth=1, + const=True, + character_length=field.character_length, + intent="in", + ) + if field.string_element + else self._scalar_parameter(field.semantic_type_name) + ) + operations.append( + self._operation( + owner_path, + f"field:{route}:set", + self._field_symbol(owner, field, route, "set"), + (*owner_values, value), + ) + ) + return tuple(operations) + def _scalar_descriptor_field_operations(self, owner, field, route, owner_path, owner_parameter): """Plan the current-storage getter and status-reporting setter of a scalar descriptor field.""" owner_values = (self._opaque_parameter("owner", fortran_name="owner_address"),) if owner_parameter else () diff --git a/prik/planning/planner.py b/prik/planning/planner.py index a85f58275..755318b65 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -172,13 +172,17 @@ def _member_proxy_fields(plan: ModulePlan) -> tuple[DerivedFieldPlan, ...]: ) -def has_scalar_descriptor_fields(plan: ModulePlan) -> bool: - """Read whether a planned field lends scalar allocatable or pointer storage.""" +def has_scalar_view_fields(plan: ModulePlan) -> bool: + """Read whether a planned field lends a rank-zero view of its own storage.""" fields = ( *(field for namespace in plan.namespaces for derived in namespace.derived_types for field in derived.fields), *_member_proxy_fields(plan), ) - return any(field.access is DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW for field in fields) + return any( + field.access + in {DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW, DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW} + for field in fields + ) def requires_address_capture(plan: ModulePlan) -> bool: @@ -195,7 +199,7 @@ def requires_address_capture(plan: ModulePlan) -> bool: field.access is DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR for field in _member_proxy_fields(plan) ) - or has_scalar_descriptor_fields(plan) + or has_scalar_view_fields(plan) ) diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 5a3ab2a8c..4a90c3ebc 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -390,17 +390,20 @@ def _derived_field_access_mechanism( ) -> DerivedFieldAccessMechanism: """Complete the typed field bridge mechanism before wrapper planning. - A nullable borrowed scalar or string getter is a scalar allocatable or - pointer field, which lends its current storage rather than a copy. + A scalar or string getter that borrows aliased storage lends the field + itself: a nullable one is a scalar allocatable or pointer, and any other + is the field's own stored value. """ if handle is not None: return DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE if ( getter.kind in {ObjectKind.SCALAR, ObjectKind.STRING} and getter.transfer is TransferMode.BORROWED_VIEW - and getter.nullable + and getter.storage_mode is StorageMode.ALIAS ): - return DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW + if getter.nullable: + return DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW + return DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW return { ObjectKind.SCALAR: DerivedFieldAccessMechanism.SCALAR_VALUE, ObjectKind.STRING: DerivedFieldAccessMechanism.FIXED_STRING_COPY, @@ -409,12 +412,23 @@ def _derived_field_access_mechanism( }[getter.kind] +# A field access that hands Python a view of the parent's storage keeps the +# parent alive for as long as the view exists. +_PARENT_RETAINING_FIELD_ACCESS = frozenset( + { + DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE, + DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW, + DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW, + } +) + + def _derived_field_owner_retention( object_kind: ObjectKind, access: DerivedFieldAccessMechanism, ) -> DerivedOwnerRetention: """Complete whether a returned field object must keep its parent alive.""" - if access in {DerivedFieldAccessMechanism.NATIVE_ARRAY_HANDLE, DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW}: + if access in _PARENT_RETAINING_FIELD_ACCESS: return DerivedOwnerRetention.PARENT_WRAPPER if object_kind in {ObjectKind.NUMPY_ARRAY, ObjectKind.DERIVED_TYPE}: return DerivedOwnerRetention.PARENT_WRAPPER @@ -700,7 +714,7 @@ def _class_constructor_policy( setter_action=field.setter_action, ) for field in derived.fields - if field.access is DerivedFieldAccessMechanism.SCALAR_VALUE + if field.access in {DerivedFieldAccessMechanism.SCALAR_VALUE, DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW} and field.semantic_type_name in _PLAN_PRIMITIVE_SCALAR_TYPES and field.setter_action is SetterAction.WRITE_THROUGH ) @@ -910,6 +924,11 @@ def _derived_field_object_kind_blockers( if access is DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW: if semantic_type.name not in _PLAN_PRIMITIVE_SCALAR_TYPES | {"String"}: blockers.append(f"field {field.name!r} is not a primitive or character scalar descriptor") + elif access is DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW: + if semantic_type.name not in _PLAN_PRIMITIVE_SCALAR_TYPES | {"String"}: + blockers.append(f"field {field.name!r} is not a primitive or character stored scalar") + elif semantic_type.name == "String" and _character_length(semantic_type) is None: + blockers.append(f"field {field.name!r} is not a fixed scalar string") elif getter.kind is ObjectKind.NUMPY_ARRAY: if semantic_type.name not in _PLAN_PRIMITIVE_SCALAR_TYPES | {"String"}: blockers.append(f"field {field.name!r} is an unsupported array of derived values") @@ -4565,10 +4584,17 @@ def _holder_field_blockers( return tuple( f"{label} holder field {field.name!r} requires unsupported {field.access.value} access" for field in type_policy.fields - if field.access is not DerivedFieldAccessMechanism.SCALAR_VALUE + if not _holder_reaches_field(field) ) +def _holder_reaches_field(field: DerivedFieldPolicy) -> bool: + """Return whether a holder's scalar-member procedures serve one field.""" + if field.string_element: + return False + return field.access in {DerivedFieldAccessMechanism.SCALAR_VALUE, DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW} + + def _derived_handoff_policy( semantic_type: models.SemanticType, decision: OwnershipDecision, diff --git a/prik/policy/models.py b/prik/policy/models.py index 3e86cb929..803df1ff1 100644 --- a/prik/policy/models.py +++ b/prik/policy/models.py @@ -379,12 +379,17 @@ class ModuleObjectAccessMechanism(str, Enum): class DerivedFieldAccessMechanism(str, Enum): """Typed bridge mechanism for one public live derived field. - ``SCALAR_DESCRIPTOR_VIEW`` lends a scalar allocatable or pointer field's - current storage as a read-only rank-zero view, or ``None``; Python writes - it only through the setter's allocating or target-copy assignment. + ``SCALAR_STORAGE_VIEW`` lends a stored numeric, logical, or fixed-length + character field as a live writable rank-zero view. ``SCALAR_DESCRIPTOR_VIEW`` + lends a scalar allocatable or pointer field's current storage as a + writable rank-zero view, or ``None``; its setter allocates or copies into + the current target. ``SCALAR_VALUE`` and + ``FIXED_STRING_COPY`` copy a field an edited contract declares as a plain + value. """ SCALAR_VALUE = "scalar_value" + SCALAR_STORAGE_VIEW = "scalar_storage_view" SCALAR_DESCRIPTOR_VIEW = "scalar_descriptor_view" FIXED_STRING_COPY = "fixed_string_copy" ORDINARY_ARRAY_DESCRIPTOR = "ordinary_array_descriptor" diff --git a/prik/policy/ownership.py b/prik/policy/ownership.py index d212ff33c..4151d9a7b 100644 --- a/prik/policy/ownership.py +++ b/prik/policy/ownership.py @@ -975,7 +975,7 @@ def decide_semantic_getter( storage = self.decide_semantic_variable(variable, context) if storage.is_blocked or storage.kind in {ObjectKind.NUMPY_ARRAY, ObjectKind.DERIVED_TYPE}: return storage - if context.is_module_variable and variable.semantic_type.metadata.get("native_storage"): + if (context.is_module_variable or context.is_field) and variable.semantic_type.metadata.get("native_storage"): return storage if (context.is_module_variable or context.is_field) and ( variable.semantic_type.metadata.get("fortran_allocatable") @@ -1022,7 +1022,8 @@ def decide_semantic_setter( incoming, assignment_mode=( AssignmentMode.VALUE_COPY - if context.is_module_variable and variable.semantic_type.metadata.get("native_storage") + if (context.is_module_variable or context.is_field) + and variable.semantic_type.metadata.get("native_storage") else AssignmentMode.ALIAS if storage.storage_mode is StorageMode.ALIAS else AssignmentMode.VALUE_COPY @@ -1038,8 +1039,8 @@ def _scalar_descriptor_assignment( ) -> AssignmentMode | None: """Select how a scalar allocatable or pointer module variable or field is assigned. - Its getter lends a read-only view of the current storage, so Python - writes only through the setter: an allocatable takes intrinsic + Its getter lends a view of the current storage, and its setter + reaches whatever storage is current: an allocatable takes intrinsic assignment, while a pointer's current target receives the value. """ if not (context.is_module_variable or context.is_field): @@ -1918,7 +1919,7 @@ def _module_variable_decision(self, facts: _StorageFacts, context: OwnershipCont boundary_storage_mode=StorageMode.ALIAS, nullable=True, borrowed=True, - reason="scalar module descriptor lends a read-only view of its current storage on each read", + reason="scalar module descriptor lends a view of its current storage on each read", ) if facts.rank > 0 or facts.is_ndarray: if facts.pointer: @@ -1980,7 +1981,18 @@ def _derived_field_decision(self, facts: _StorageFacts, context: OwnershipContex boundary_storage_mode=StorageMode.ALIAS, nullable=True, borrowed=True, - reason="scalar field descriptor lends a read-only view of its current storage on each read", + reason="scalar field descriptor lends a view of its current storage on each read", + ) + if (facts.metadata or {}).get("native_storage") and facts.rank == 0 and not facts.is_custom: + return OwnershipDecision( + self._kind(facts, OwnershipContext()), + OwnershipOwner.WRAPPER, + TransferMode.BORROWED_VIEW, + DestructionPolicy.WRAPPER_DEALLOC, + storage_mode=StorageMode.ALIAS, + boundary_storage_mode=StorageMode.ALIAS, + borrowed=True, + reason="stored scalar field lends a live view of its storage", ) if facts.allocatable and facts.rank == 0: return self._allocatable_scalar_decision(facts, context) diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index fbfec2063..7af33d4a2 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -773,7 +773,7 @@ def _convert_data_member( declaration_arrays=declaration_arrays, ) if ( - source_kind == "variable" + source_kind in {"variable", "field"} and var.rank == 0 and not var.is_parameter and not getattr(var, "allocatable", False) diff --git a/prik/semantics/pyi2ir.py b/prik/semantics/pyi2ir.py index b5a16a688..db935573a 100644 --- a/prik/semantics/pyi2ir.py +++ b/prik/semantics/pyi2ir.py @@ -836,6 +836,14 @@ def ann_assign( visibility=visibility, default_value=self.assignment_default_value(node.value, semantic_type), ) + storage = semantic_type.storage + if storage is not None and storage.array is not None and storage.array.category == SCALAR_STORAGE_CATEGORY: + # `T[()]` states the declaration's own rank-zero native storage, + # which Python reads as a live view rather than a copied value. + if self.native_language != "fortran": + raise ValueError("rank-zero stored scalars are only supported for Fortran") + semantic_type.storage = None + semantic_type.metadata["native_storage"] = True 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 @@ -3806,14 +3814,7 @@ def _visit_ImportFrom(self, node: ast.ImportFrom) -> None: def _visit_AnnAssign(self, node: ast.AnnAssign) -> None: """Convert a module variable declaration.""" - variable = self.parser.ann_assign(node) - storage = variable.semantic_type.storage - if storage is not None and storage.array is not None and storage.array.category == SCALAR_STORAGE_CATEGORY: - if self.parser.native_language != "fortran": - raise ValueError("rank-zero module storage is only supported for Fortran") - variable.semantic_type.storage = None - variable.semantic_type.metadata["native_storage"] = True - self.parser.module.variables.append(variable) + 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.""" diff --git a/tests/fortran/allocatables/end_to_end/test_scalar_allocatables.py b/tests/fortran/allocatables/end_to_end/test_scalar_allocatables.py index d7e1e9bc0..edcb96822 100644 --- a/tests/fortran/allocatables/end_to_end/test_scalar_allocatables.py +++ b/tests/fortran/allocatables/end_to_end/test_scalar_allocatables.py @@ -34,15 +34,15 @@ def test_scalar_allocatables_project_values_and_unallocated_state( assert not hasattr(module, "get_optional_scale") assert not hasattr(module, "set_optional_scale") - # Assignment allocates the unallocated variable; the view it reads is live but read-only. + # Assignment allocates the unallocated variable; the view it reads writes + # the current storage, which native code then reads. module.optional_scale = np.float64(2.5) view = module.optional_scale assert view is not None and view.shape == () and view.dtype == np.dtype("float64") assert view[()] == np.float64(2.5) - with pytest.raises(ValueError, match="read-only"): - view[()] = np.float64(3.5) + view[()] = np.float64(3.5) module.bump_module_value() - assert view[()] == np.float64(12.5) + assert view[()] == np.float64(13.5) # Native reallocation replaces the storage, so the attribute is read again. module.set_module_value(np.float64(1.5)) 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 dfd94bd7b..11b4e36f6 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 @@ -8,8 +8,8 @@ class Point_T: y: Float64 = ... ) -> None: ... - x: Float64 - y: Float64 + x: Float64[()] + y: Float64[()] @prototype def value_callback( 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 e6d0d4e18..12584d73d 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 @@ -9,8 +9,8 @@ class Point: axis: Int32 = ... ) -> None: ... - x: Float64 - axis: Int32 + x: Float64[()] + axis: Int32[()] @native_abi("c") class Tagged_Point: @@ -21,7 +21,7 @@ class Tagged_Point: ) -> None: ... position: Point - weight: Complex128 + weight: Complex128[()] @native_abi("c") def populate( 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 5eef0d5b3..4e142c434 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 @@ -8,8 +8,8 @@ class Vector: y: Float64 = ... ) -> None: ... - x: Float64 - y: Float64 + x: Float64[()] + y: Float64[()] @native_call([Pass(), Addr(Arg(0))]) def scale( 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 ad28c4c5c..8c5c4cdf7 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 @@ -11,8 +11,8 @@ class Initialized: @destroy def cleanup_initialized(self) -> None: ... - id: Int32 = 7 - scale: Float64 = 2.5 + id: Int32[()] = 7 + scale: Float64[()] = 2.5 def get_final_count() -> Int32: ... 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 89880a24d..717191370 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 @@ -8,8 +8,8 @@ class Point: y: Float64 = ... ) -> None: ... - x: Float64 - y: Float64 + x: Float64[()] + y: Float64[()] class Holder: def __init__( @@ -19,7 +19,7 @@ class Holder: ) -> None: ... origin: Point - scale: Float64 + scale: Float64[()] def point_sum( p: Point 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 9f0a8fb02..c49e92dc6 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 @@ -7,7 +7,7 @@ class Base_Shape: size: Float64 = ... ) -> None: ... - size: Float64 + size: Float64[()] @bind("base_area") def area(self) -> Float64: ... @@ -26,7 +26,7 @@ class Circle(Base_Shape): radius: Float64 = ... ) -> None: ... - radius: Float64 + radius: Float64[()] @bind("circle_area") def area(self) -> Float64: ... @@ -38,7 +38,7 @@ class Box(Base_Shape): width: Float64 = ... ) -> None: ... - width: Float64 + width: Float64[()] @bind("box_area") def area(self) -> Float64: ... diff --git a/tests/fortran/derived_types/end_to_end/fixtures/edited_contracts/scalar_actual_dummy_matrix/fscalar_derived_actual_dummy_matrix_f90.pyi b/tests/fortran/derived_types/end_to_end/fixtures/edited_contracts/scalar_actual_dummy_matrix/fscalar_derived_actual_dummy_matrix_f90.pyi index c6c620ec7..cfc5b5e31 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/edited_contracts/scalar_actual_dummy_matrix/fscalar_derived_actual_dummy_matrix_f90.pyi +++ b/tests/fortran/derived_types/end_to_end/fixtures/edited_contracts/scalar_actual_dummy_matrix/fscalar_derived_actual_dummy_matrix_f90.pyi @@ -17,7 +17,7 @@ from matrix_right_types import item as right_item class item: - value: Int32 + value: Int32[()] class sequence_item: diff --git a/tests/fortran/derived_types/end_to_end/fixtures/native/fderived_scalar_descriptor_fields.f90 b/tests/fortran/derived_types/end_to_end/fixtures/native/fderived_scalar_descriptor_fields.f90 index e1e56d8c1..91ba0a362 100644 --- a/tests/fortran/derived_types/end_to_end/fixtures/native/fderived_scalar_descriptor_fields.f90 +++ b/tests/fortran/derived_types/end_to_end/fixtures/native/fderived_scalar_descriptor_fields.f90 @@ -3,14 +3,32 @@ module scalar_descriptor_fields type :: record real(8) :: plain = 0 + character(len=4) :: label = 'none' real(8), allocatable :: scale real(8), pointer :: weight => null() character(len=:), allocatable :: name character(len=4), pointer :: tag => null() end type record + type :: counter + integer :: count = 0 + end type counter + type(record) :: shared contains + function make_counter(start) result(item) + integer, intent(in) :: start + type(counter), allocatable :: item + allocate(item) + item%count = start + end function make_counter + + function counter_value(item) result(value) + type(counter), intent(in) :: item + integer :: value + value = item%count + end function counter_value + subroutine fill(item) type(record), intent(inout) :: item item%scale = 1.5d0 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 ea96225ca..afcfee143 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 @@ -9,8 +9,8 @@ class Point: y: Float64 = ... ) -> None: ... - x: Float64 - y: Float64 + x: Float64[()] + y: Float64[()] @native_abi("c") def direct_sum( 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 34bd41cb2..9a9b202bf 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 @@ -9,8 +9,8 @@ class Point: y: Float64 = ... ) -> None: ... - x: Float64 - y: Float64 + x: Float64[()] + y: Float64[()] @native_abi("c") def direct_sum( 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 0758364f9..399987229 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 @@ -211,8 +211,8 @@ def _assert_scalar_descriptor_fields(module) -> None: weight = record.weight assert weight.shape == () and weight.dtype == np.float64 and float(weight) == 2.5 assert record.name.dtype == np.dtype("S5") and record.name[()] == b"hello" - with pytest.raises(ValueError, match="read-only"): - weight[...] = 0.0 + weight[...] = 6.0 + assert module.total(record) == 7.5 # Assignment allocates or writes the current target, and the earlier view # of the pointer target sees the write. @@ -239,7 +239,33 @@ def _assert_scalar_descriptor_fields(module) -> None: assert module.shared_name() == "abc" -def test_scalar_descriptor_fields_lend_live_storage_in_source_and_contract_builds(tmp_path: Path): +def _assert_stored_fields_are_live_views(module) -> None: + """Check that stored scalar fields lend writable storage on every route.""" + record = module.Record(plain=np.float64(5.0)) + plain, label = record.plain, record.label + assert plain.shape == () and plain.flags.writeable and plain.dtype == np.float64 + assert label.dtype == np.dtype("S4") and label.flags.writeable + plain[...] = 9.0 + record.plain = np.float64(3.0) + assert float(plain) == 3.0 + + module.fill(record) + label[...] = b"wxyz" + assert record.tag[()] == b"abcd" and record.label[()] == b"wxyz" + + # A module object's member and an allocatable result lend the same storage + # their Fortran procedures read. + module.shared.plain[...] = 4.0 + assert float(module.shared.plain) == 4.0 + item = module.make_counter(np.int32(2)) + count = item.count + count[...] = 11 + assert module.counter_value(item) == 11 + item.count = np.int32(12) + assert int(count) == 12 + + +def test_scalar_fields_lend_live_storage_in_source_and_contract_builds(tmp_path: Path): source_module = _build_source_and_import( SCALAR_DESCRIPTOR_FIELD_SOURCE, tmp_path / "source", @@ -253,13 +279,15 @@ def test_scalar_descriptor_fields_lend_live_storage_in_source_and_contract_build for module in (source_module, contract_module): _assert_scalar_descriptor_fields(module) + _assert_stored_fields_are_live_views(module) # The generated contract's constructor states the keywords both builds accept. contract = (tmp_path / "contract" / "contracts" / "fderived_scalar_descriptor_fields").rglob("*.pyi") constructor = next( text for text in (path.read_text(encoding="utf-8") for path in contract) if "class Record" in text ) - assert "plain: Float64 = 0" in constructor + assert "plain: Float64 = 0" in constructor and "plain: Float64[()] = 0" in constructor + assert "label: String[4][()]" in constructor assert "scale: Allocatable[Float64]\n" in constructor and "scale: Allocatable[Float64] =" not in constructor diff --git a/tests/fortran/derived_types/end_to_end/test_scalar_actual_dummy_matrix.py b/tests/fortran/derived_types/end_to_end/test_scalar_actual_dummy_matrix.py index 4ec5379b3..4b0848856 100644 --- a/tests/fortran/derived_types/end_to_end/test_scalar_actual_dummy_matrix.py +++ b/tests/fortran/derived_types/end_to_end/test_scalar_actual_dummy_matrix.py @@ -218,6 +218,14 @@ def test_wrapper_owned_empty_holders_fill_in_place_and_pointer_holders_track_the assert module.set_pointer(pointer, np.int32(2)) is pointer assert (allocatable.value, allocatable_target.value, pointer.value) == (7, 8, 60) + # A holder lends its object's storage, so a write through the view is + # what the next native reader observes. + for holder in (allocatable, pointer): + view = holder.value + view[...] = 99 + assert module.read_object(holder) == 99 + module.set_pointer(pointer, np.int32(2)) + # A pointer holder retains its native owner and follows reassociation and nullification. assert pointer._prik_owner is module assert module.set_pointer(pointer, np.int32(3)) is pointer 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 3111cce95..bb793f5d2 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 @@ -7,7 +7,7 @@ class Paint: color: Int32 = ... ) -> None: ... - color: Int32 + color: Int32[()] red: Final[Int32] = -1 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 2af65a6ed..f31b36dfb 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 @@ -7,7 +7,7 @@ class Vector: value: Float64 = 0.0 ) -> None: ... - value: Float64 = 0.0 + value: Float64[()] = 0.0 @overload("add_vectors") def __add__( @@ -187,7 +187,7 @@ class Offset: value: Float64 = 0.0 ) -> None: ... - value: Float64 = 0.0 + value: Float64[()] = 0.0 @overload("add_vector_offset") def __radd__( @@ -208,7 +208,7 @@ class Counter: value: Int32 = 0 ) -> None: ... - value: Int32 = 0 + value: Int32[()] = 0 @private @bind("counter_add_integer") 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 173490b6b..0e30745b2 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 @@ -7,7 +7,7 @@ class Accumulator: total: Float64 = 0.0 ) -> None: ... - total: Float64 = 0.0 + total: Float64[()] = 0.0 @private @bind("accumulator_add_integer") @@ -46,7 +46,7 @@ class Sample: value: Float64 = 0.0 ) -> None: ... - value: Float64 = 0.0 + value: Float64[()] = 0.0 @private @native_call([Addr(Arg(0))]) 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 a2f48835e..8154b2710 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 @@ -7,7 +7,7 @@ class Box: value: Int32 = ... ) -> None: ... - value: Int32 + value: Int32[()] @native_call([Addr(Arg(0))]) def make_box( 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 057bba4cc..d4c82bfeb 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 @@ -37,7 +37,9 @@ "coercions": [], "constraints": [], "dtype": "Int32", - "metadata": {}, + "metadata": { + "native_storage": true + }, "name": "Int32", "origin": { "metadata": { 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 7c00c8bc9..2d4c7157c 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 @@ -37,7 +37,9 @@ "coercions": [], "constraints": [], "dtype": "Int32", - "metadata": {}, + "metadata": { + "native_storage": true + }, "name": "Int32", "origin": { "metadata": { @@ -266,7 +268,9 @@ "coercions": [], "constraints": [], "dtype": "Int32", - "metadata": {}, + "metadata": { + "native_storage": true + }, "name": "Int32", "origin": { "metadata": { 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 5e3569e96..b4f49ce91 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 @@ -37,7 +37,9 @@ "coercions": [], "constraints": [], "dtype": "Int32", - "metadata": {}, + "metadata": { + "native_storage": true + }, "name": "Int32", "origin": { "metadata": { @@ -102,7 +104,9 @@ "coercions": [], "constraints": [], "dtype": "Float64", - "metadata": {}, + "metadata": { + "native_storage": true + }, "name": "Float64", "origin": { "metadata": { @@ -495,7 +499,9 @@ "coercions": [], "constraints": [], "dtype": "Int32", - "metadata": {}, + "metadata": { + "native_storage": true + }, "name": "Int32", "origin": { "metadata": { 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 e415e62a6..da76fc913 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 @@ -37,7 +37,9 @@ "coercions": [], "constraints": [], "dtype": "Int32", - "metadata": {}, + "metadata": { + "native_storage": true + }, "name": "Int32", "origin": { "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 81d4f5298..7d8a0e02d 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 @@ -8,8 +8,8 @@ class Visible_T: lambda__2: Annotated[Int32, SourceName("lambda_")] = 4 ) -> None: ... - lambda_: Annotated[Int32, SourceName("lambda")] = 3 - lambda__2: Annotated[Int32, SourceName("lambda_")] = 4 + lambda_: Annotated[Int32[()], SourceName("lambda")] = 3 + lambda__2: Annotated[Int32[()], SourceName("lambda_")] = 4 @bind("Visible_T.from") def from_(self) -> Int32: ... 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 5e081762e..ab9bb647b 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 @@ -8,8 +8,8 @@ class particle: mass: Float64 = ... ) -> None: ... - id: Int32 - mass: Float64 + id: Int32[()] + mass: Float64[()] position: Float64[3] class vector3: 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 71da8f35f..763f62b84 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 @@ -197,7 +197,7 @@ def _fixture(name: str) -> str: "class sparse_matrix(base_matrix):", "class state:\n def __init__(\n self,\n *,\n id: Int32 = 7,\n" " scale: Float64 = 2.5,\n enabled: Bool = True\n ) -> None: ...\n\n" - " id: Int32 = 7\n scale: Float64 = 2.5\n enabled: Bool = True\n", + " id: Int32[()] = 7\n scale: Float64[()] = 2.5\n enabled: Bool[()] = True\n", " values: Allocatable[Float64[:]]", " @native_call([Pass(), Addr(Arg(0))])\n def scale(\n self,\n alpha: Float64\n" " ) -> None: ...", 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 1bcc304a5..8b2ccf5c4 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 @@ -9,9 +9,9 @@ class Rgb_Color: b: Int32 = ... ) -> None: ... - r: Int32 - g: Int32 - b: Int32 + r: Int32[()] + g: Int32[()] + b: Int32[()] nmax: Final[Int32] = 12 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 2ada2a1ba..5105fab3f 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 @@ -313,8 +313,9 @@ def test_descriptor_character_module_variables_follow_current_storage(pyi_parity assert fixed is not None and fixed[()] == b"FIXEDV" assert view is not None and view.shape == () and view.dtype == np.dtype("S6") assert view[()] == b"STORED" - with pytest.raises(ValueError, match="read-only"): - view[()] = b"PYTHON" + # The pointer view and the attribute both write the pointer's target. + view[()] = b"VIEWED" + assert module.store[()] == b"VIEWED" module.link = "PYTHON" assert view[()] == b"PYTHON" assert module.store[()] == b"PYTHON" 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 1b440da59..0cbc3e396 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 @@ -7,7 +7,7 @@ class Sample: value: Int32 = ... ) -> None: ... - value: Int32 + value: Int32[()] @native_call([Addr(Arg(0)), Addr(Arg(1)), Arg(2), Arg(3), Arg(4)]) def summarize( diff --git a/tests/fortran/pointers/end_to_end/test_scalar_pointers.py b/tests/fortran/pointers/end_to_end/test_scalar_pointers.py index 64139838f..b9e5fcc9b 100644 --- a/tests/fortran/pointers/end_to_end/test_scalar_pointers.py +++ b/tests/fortran/pointers/end_to_end/test_scalar_pointers.py @@ -71,17 +71,16 @@ def test_scalar_pointers_project_nullable_native_views( with pytest.raises(ValueError, match="no pointer target"): module.selected_scale = np.float64(9.0) - # Assignment writes the current target; the view it reads is live but read-only. + # Assignment and the view it reads both write the current target. module.point_to_target(np.float64(2.5)) module.selected_scale = np.float64(3.5) view = module.selected_scale assert view is not None and view.shape == () and view.dtype == np.dtype("float64") assert view[()] == np.float64(3.5) - with pytest.raises(ValueError, match="read-only"): - view[()] = np.float64(4.5) + view[()] = np.float64(4.5) module.bump_native() - assert view[()] == np.float64(23.5) - assert module.selected_scale[()] == np.float64(23.5) + assert view[()] == np.float64(24.5) + assert module.selected_scale[()] == np.float64(24.5) assert module.echo_pointer(np.float64(3.0)) == np.float64(5.0) assert module.echo_pointer(None) == np.float64(-2.0) From 49d610d683c0575c1c7a88679db0bf7f52a98d45 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 27 Sep 2026 16:16:57 +0100 Subject: [PATCH 03/11] codex: support UCS-4 character values through UString Fortran character(kind=selected_char_kind('ISO_10646')) declarations are wrapped through the new UString contract type, which takes every form String does and maps fixed-width storage to NumPy U instead of S. Kinds that select ASCII or DEFAULT are ordinary Strings; a compiler without ISO_10646 is reported as not providing the character set. String and UString share one path. The type name carries the character width, one helper answers whether a type is a string and how wide its characters are, and every stage reads that width: the bridge declares the matching character kind and sizes byte buffers, the binding selects the UTF-8 or UCS-4 codec, NumPy dtype, and itemsize, and the runtime helpers take the width. For the default kind every stage emits the code it did before. A UCS-4 str input is converted into the call buffer mutable strings already use, because a str holds no UCS-4 storage to lend. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + README.md | 3 +- docs/user/guide/strings.md | 28 ++ docs/user/language-support/feature-matrix.md | 2 +- docs/user/reference/pyi-format.md | 3 +- prik/codegen/c/binding.py | 412 +++++++++++++----- prik/codegen/docstrings.py | 12 +- prik/codegen/fortran/bridge.py | 233 +++++++--- prik/codegen/primitive_scalar_types.py | 1 + prik/contracts/__init__.py | 15 +- prik/pipeline/type_mapping_report.py | 3 +- prik/pipeline/wrapper.py | 3 +- prik/planning/entrypoints.py | 8 +- prik/planning/planner.py | 8 +- prik/policy/completion.py | 27 +- prik/policy/construction.py | 102 +++-- prik/policy/ownership.py | 7 +- prik/preprocessing/probes/fortran_types.py | 12 +- prik/printers/pyi.py | 22 +- prik/runtime/handles.py | 11 +- prik/runtime/native_support/prik_binding.h | 149 ++++++- prik/semantics/fortran2ir.py | 23 +- prik/semantics/pyi2ir.py | 31 +- prik/semantics/scalar_types.py | 24 + tests/fortran/_support/wrapper_build.py | 22 + .../probes/test_fortran_type_probes.py | 11 + .../fixtures/native/fucs4_strings.f90 | 77 ++++ .../strings/end_to_end/test_ucs4_strings.py | 73 ++++ .../test_fortran_string_semantics.py | 31 ++ .../semantics/test_string_pyi_semantics.py | 39 +- 30 files changed, 1068 insertions(+), 329 deletions(-) create mode 100644 tests/fortran/strings/end_to_end/fixtures/native/fucs4_strings.f90 create mode 100644 tests/fortran/strings/end_to_end/test_ucs4_strings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 281a95d0c..7e561d7fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ release tags add a leading `v` to the package version. ## Unreleased +- Fortran `character(kind=selected_char_kind('ISO_10646'))` (UCS-4) values are + supported through the new `UString` contract type, which takes every form + `String` does and maps storage to NumPy `U` instead of `S`. A + character kind given by `selected_char_kind('ASCII')` or `'DEFAULT'` is now + an ordinary `String` instead of an unsupported kind. - **Breaking:** scalar fields of a Fortran derived type read as live rank-zero NumPy views of the object's storage, as module variables do. A numeric or logical field returns a writable `T[()]` view instead of a NumPy scalar, and diff --git a/README.md b/README.md index 8472a7c75..7cd1df86c 100644 --- a/README.md +++ b/README.md @@ -237,8 +237,7 @@ code generation with a diagnostic naming the boundary and the reason. - Arrays of derived types. - Parameterized derived types such as `type :: buffer_type(k, n)`. -- Character arrays that cannot be represented as a fixed-width NumPy bytes - dtype. +- `character` kinds other than the default kind and `ISO_10646` (UCS-4). - Real and complex storage wider than the target's `long double`. NumPy's `longdouble` is whatever the target C compiler provides, so `real(10)` and C `long double` are supported while IEEE quad `real(16)` is refused on a target diff --git a/docs/user/guide/strings.md b/docs/user/guide/strings.md index b8408cc59..9e312d524 100644 --- a/docs/user/guide/strings.md +++ b/docs/user/guide/strings.md @@ -250,6 +250,34 @@ b'Xlpha ' - `String[8][()]` and `String[8][count]` require dtype `S8`. - A dummy without `intent` uses the conservative `intent(inout)` behavior. +## Unicode Strings + +A Fortran `character(kind=selected_char_kind('ISO_10646'))` declaration stores +four bytes per character (UCS-4). Its contract uses `UString` wherever a +default-kind declaration uses `String`, and every form above works the same +way: + +| Contract | Python value | +| --- | --- | +| `UString[8]` | `str` or rank-zero NumPy `U8` array | +| `UString[8][()]` | Rank-zero NumPy array with dtype `U8` | +| `UString[8][count]` | NumPy array with dtype `U8` | + +```fortran +integer, parameter :: ucs4 = selected_char_kind('ISO_10646') +character(kind=ucs4, len=8) :: title +``` + +```python +title: UString[8][()] +``` + +A `UString[8]` length counts characters, not bytes, so it accepts any `str` of +exactly eight characters. Declare the kind with +`selected_char_kind('ISO_10646')`, and use a compiler that provides it: GNU +Fortran and LLVM Flang do, Intel `ifx` does not. A kind that selects +`'ASCII'` or `'DEFAULT'` is an ordinary `String`. + ## Allocatable And Pointer Scalar Strings A scalar `character` dummy may carry the `allocatable` or `pointer` attribute, diff --git a/docs/user/language-support/feature-matrix.md b/docs/user/language-support/feature-matrix.md index f7208af23..fa64dbbbd 100644 --- a/docs/user/language-support/feature-matrix.md +++ b/docs/user/language-support/feature-matrix.md @@ -70,7 +70,7 @@ where they apply. | Module variables, constants, saved state, and common-block procedure state | Supported | [Wrapping modules](../guide/wrapping-modules.md) | [Module state tests](../../../tests/fortran/modules/end_to_end/test_module_variables_and_state.py), [scalar-derived matrix tests](../../../tests/fortran/derived_types/end_to_end/test_scalar_actual_dummy_matrix.py), [common-block tests](../../../tests/fortran/modules/end_to_end/test_common_blocks.py) | Common-block storage is not exported as Python variables. Rank-zero derived module objects use direct, scoped, allocation-transaction, or pointer-transaction handoff selected before lowering. Mutable fixed-storage scalars are live rank-zero NumPy views, written through the view or by assigning the attribute; a declared-length `character` scalar is a fixed-width bytes view (`String[n][()]`) that assignment fills at exactly its declared width. `allocatable` and `pointer` scalars, numeric and `character` alike, read as live rank-zero views or `None`, and assigning the attribute allocates an allocatable (resizing a deferred-length `character`) or writes a pointer's current target. `character` arrays reach Python as fixed-width bytes, and arrays are mutated in place through their view or handle rather than rebound. | | Fortran enum constants | Supported | [Enumerations](../guide/enumerations.md) | [Enum runtime tests](../../../tests/fortran/enumerations/end_to_end/test_enum_runtime.py), [enum semantic tests](../../../tests/fortran/enumerations/semantics/test_enum_semantics.py), [enum diagnostics](../../../tests/fortran/enumerations/parsing/test_enum_diagnostics.py) | No Python `Enum` or `IntEnum` classes are generated. | | Scalar character arguments, results, and fields | Supported | [Strings](../guide/strings.md) | [Character argument tests](../../../tests/fortran/strings/end_to_end/test_character_boundaries.py), [edge-case tests](../../../tests/fortran/strings/end_to_end/test_character_edge_cases.py) | Character arrays use fixed-width NumPy bytes dtype. Scalar `character` `allocatable` and `pointer` values are supported for `intent(in)`, `intent(out)`, `intent(inout)`, and function results, at deferred (`len=:`) and declared (`len=n`) length; a mutable dummy returns the value the procedure left behind, or `None`. PRIK frees the target it allocated for the call while it can still prove that identity, but never a target the procedure reassociated or the library owns; a procedure that returns a fresh allocation each call leaks unless it frees its own. | -| Character arrays and caller-supplied deferred-length character storage | Supported | [Strings](../guide/strings.md) | [Character edge tests](../../../tests/fortran/strings/end_to_end/test_character_edge_cases.py) | Character arrays use fixed-width NumPy bytes dtype, whose width each accessor reports from the Fortran declaration; Unicode/object arrays are unsupported. Scalar `character` `allocatable` and `pointer` values work for every intent and as function results. A mutable `pointer` dummy that the native procedure reassociates without deallocating orphans the target the adapter allocated for that call. | +| Character arrays and caller-supplied deferred-length character storage | Supported | [Strings](../guide/strings.md) | [Character edge tests](../../../tests/fortran/strings/end_to_end/test_character_edge_cases.py) | Character arrays use fixed-width NumPy bytes dtype, whose width each accessor reports from the Fortran declaration. `character(kind=selected_char_kind('ISO_10646'))` uses `UString` contracts and NumPy `U` storage on compilers that provide the kind. Scalar `character` `allocatable` and `pointer` values work for every intent and as function results. A mutable `pointer` dummy that the native procedure reassociates without deallocating orphans the target the adapter allocated for that call. | | Scalar kind coverage | Supported | [Data types](../guide/data-types.md) | [Scalar kind tests](../../../tests/fortran/data_types/end_to_end/test_primitive_scalar_runtime.py) | Real and complex storage wider than the target's `long double` is blocked. Logical scalars use Python `bool`; arrays use their documented NumPy dtype. | | Multi-source builds, Makefiles, verbose mode, and output placement | Supported | [Building the shared library](../guide/building-shared-library.md) | [Multi-source tests](../../../tests/fortran/infrastructure/building/end_to_end/test_multi_source_builds.py), [compiler verbose tests](../../../tests/fortran/infrastructure/building/compiling/test_compiler_verbose.py) | Wrapped project sources compile in dependency order derived from their module/`use` graph, falling back to the given order when a compiled source was not parsed. PRIK does not discover sources you did not name, prebuilt module paths, or external libraries. | | Visibility, naming, keyword escaping, and collision policy | Supported | [Generic interfaces](../guide/generic-interfaces.md#key-rules) | [Visibility/naming tests](../../../tests/fortran/infrastructure/semantic_pyi/contracts/exports_and_modules/end_to_end/test_visibility_naming.py) | Strict mode rejects names that default mode can normalize. | diff --git a/docs/user/reference/pyi-format.md b/docs/user/reference/pyi-format.md index 39ea8c555..06efd9361 100644 --- a/docs/user/reference/pyi-format.md +++ b/docs/user/reference/pyi-format.md @@ -860,6 +860,7 @@ subscription for storage shape: | `String[:]` | Deferred | Scalar descriptor value, with `Allocatable` or `Pointer` policy. | | `String[n][()]` | Fixed `n` | Rank-zero NumPy bytes storage. | | `String[n][:]` | Fixed `n` | Rank-one NumPy bytes array. | +| `UString[...]` forms | As for `String` | Four-byte (UCS-4) characters: a `str`, or NumPy `U` storage where `String` uses `S`. | | `String[:][:]` | Deferred | Rank-one character array contract. | A single `String[...]` subscription is always a length, never an array shape. @@ -1108,7 +1109,7 @@ valid and whether it is buildable. | Group | Public names | | --- | --- | | Typing forms | `Annotated`, `Any`, `Final` | -| Scalar types | `AnyNative`, `Bool`, `Bool8`, `Bool16`, `Bool32`, `Bool64`, `Byte`, `CEnum`, `Char`, `Complex64`, `Complex128`, `Complex256`, `Float16`, `Float32`, `Float64`, `Float128`, `Int`, `Int8`, `Int16`, `Int32`, `Int64`, `SizeT`, `String`, `UInt`, `UInt8`, `UInt16`, `UInt32`, `UInt64`, `Void` | +| Scalar types | `AnyNative`, `Bool`, `Bool8`, `Bool16`, `Bool32`, `Bool64`, `Byte`, `CEnum`, `Char`, `Complex64`, `Complex128`, `Complex256`, `Float16`, `Float32`, `Float64`, `Float128`, `Int`, `Int8`, `Int16`, `Int32`, `Int64`, `SizeT`, `String`, `UInt`, `UInt8`, `UInt16`, `UInt32`, `UInt64`, `UString`, `Void` | | 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` | diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 8fc075667..c8c026444 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -67,7 +67,11 @@ from prik.codegen.c.naming import CBindingNames from prik.naming.generated_files import adapter_module_name, binding_module_name from prik.codegen.c.python_surface import PythonSurfaceContext, PythonSurfaceEmitter -from prik.semantics.scalar_types import is_boolean_semantic_type_name +from prik.semantics.scalar_types import ( + character_width, + is_boolean_semantic_type_name, + is_string_semantic_type_name, +) from prik.codegen.nodes import ( CAllowThreadsBegin, CAllowThreadsEnd, @@ -243,6 +247,64 @@ class _COverloadDispatch: ) +def _decode_terminated_text(data: str, semantic_type_name: str | None) -> str: + """Return the Python str built from one NUL-terminated native character copy.""" + width = character_width(semantic_type_name or "") + if width == 1: + return f'Py_BuildValue("s", (const char *){data})' + return f"prik_character_decode_terminated({data}, {width})" + + +def _uses_character_call_buffer(argument) -> bool: + """Return whether a string argument crosses in a binding-owned call buffer. + + A mutable string copies in and out through one. A four-byte input needs + one too: a str holds no UCS-4 storage to lend, so its characters are + converted into the buffer instead of borrowed. + """ + if argument.object_kind is not ObjectKind.STRING: + return False + action = argument.binding.codegen_action + return action is CodegenAction.COPY_IN_OUT or ( + action is CodegenAction.CALL_LOCAL_INPUT and character_width(argument.semantic_type_name) == 4 + ) + + +def _character_dtype(semantic_type_name: str | None) -> str: + """Return the NumPy type number of fixed-width storage for one string type.""" + return "NPY_UNICODE" if character_width(semantic_type_name or "") == 4 else "NPY_STRING" + + +def _character_itemsize(length: object, semantic_type_name: str | None) -> str: + """Return the NumPy itemsize of ``length`` characters of one string type.""" + width = character_width(semantic_type_name or "") + return str(length) if width == 1 else f"({length}) * {width}" + + +def _character_c_type(semantic_type_name: str | None) -> str: + """Return the C element type that holds one character of a string type.""" + return "Py_UCS4" if character_width(semantic_type_name or "") == 4 else "char" + + +def _dtype_code(semantic_type_name: str | None) -> str: + """Return the NumPy dtype letter of fixed-width storage for one string type.""" + return "U" if character_width(semantic_type_name or "") == 4 else "S" + + +def _terminator(buffer: str, length: int, semantic_type_name: str | None) -> str: + """Return the statement ending a native character copy with one NUL character.""" + if character_width(semantic_type_name or "") == 1: + return f"{buffer}[{length}] = '\\0'" + return f"{buffer}[{length}] = 0" + + +def _decode_text(data: str, length: str, semantic_type_name: str | None) -> str: + """Return the Python str built from ``length`` native characters at ``data``.""" + if character_width(semantic_type_name or "") == 4: + return f"prik_character_decode({data}, (Py_ssize_t)({length}), 4)" + return f'PyUnicode_DecodeUTF8({data}, {length}, "strict")' + + class CBindingGenerator(ClassVisitor): """Build the CPython C half of a wrapper from validated binding-plan views. @@ -276,7 +338,7 @@ def _require_variable_supported(self, variable: ModuleVariablePlan) -> None: def _require_derived_type_supported(self, derived: DerivedTypePlan) -> None: """Preflight primitive field types after shared plan validation.""" for field in derived.fields: - if field.semantic_type_name != "String" and field.derived is None: + if not is_string_semantic_type_name(field.semantic_type_name) and field.derived is None: PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name) @staticmethod @@ -314,7 +376,10 @@ def _require_argument_supported(self, argument: ArgumentTransferPlan) -> None: *((argument.callback.result.transfer,) if argument.callback.result.transfer is not None else ()), ) for transfer in transfers: - if transfer.semantic_type_name != "String" and transfer.derived_type_identity is None: + if ( + not is_string_semantic_type_name(transfer.semantic_type_name) + and transfer.derived_type_identity is None + ): PrimitiveScalarTypeRegistry.type_for(transfer.semantic_type_name) return self._require_backend_type_supported(argument.semantic_type_name, argument.datatype_family) @@ -453,7 +518,6 @@ def _support_procedure_c_type(value: NativeEntrypointABIValuePlan) -> str: NativeEntrypointABIValueKind.INT8: "int8_t", NativeEntrypointABIValueKind.INT64: "int64_t", NativeEntrypointABIValueKind.OPAQUE: "void", - NativeEntrypointABIValueKind.CHARACTER: "char", NativeEntrypointABIValueKind.DESCRIPTOR: "CFI_cdesc_t", } if value.kind is NativeEntrypointABIValueKind.SEMANTIC_SCALAR: @@ -464,6 +528,10 @@ def _support_procedure_c_type(value: NativeEntrypointABIValuePlan) -> str: if value.c_type_name is None: raise ValueError(f"Generated-support ABI callback {value.role!r} has no C typedef") base = value.c_type_name + elif value.kind is NativeEntrypointABIValueKind.CHARACTER: + if value.semantic_type_name is None: + raise ValueError(f"Generated-support ABI character {value.role!r} has no string type") + base = _character_c_type(value.semantic_type_name) else: try: base = base_types[value.kind] @@ -703,10 +771,7 @@ def _function_needs_allocator(self, function: FunctionPlan) -> bool: return any( result.scalar_descriptor is not None or result.object_kind in {ObjectKind.STRING, ObjectKind.NUMPY_ARRAY} for result in function.entrypoint.results - ) or any( - argument.object_kind is ObjectKind.STRING and argument.binding.codegen_action is CodegenAction.COPY_IN_OUT - for argument in function.arguments - ) + ) or any(_uses_character_call_buffer(argument) for argument in function.arguments) def _module_defines(self, plan: ModulePlan, needs_native_support: bool) -> tuple[CMacroDefinition, ...]: """Select native-support sections required by the completed module plan.""" @@ -1190,11 +1255,16 @@ def _callback_string_nodes( """Build callback string nodes from the supplied local lowering values; emitted nodes only project completed binding actions.""" base = self._callback_parameter_base_name(transfer) if transfer.adapter_action is CallbackTransferAction.COPY_IN: - expression = f"PyUnicode_FromStringAndSize((const char *){base}_data, (Py_ssize_t){base}_length)" + expression = ( + f"PyUnicode_FromStringAndSize((const char *){base}_data, (Py_ssize_t){base}_length)" + if character_width(transfer.semantic_type_name) == 1 + else _decode_text(f"{base}_data", f"{base}_length", transfer.semantic_type_name) + ) else: expression = ( - f"PyArray_New(&PyArray_Type, 0, NULL, NPY_STRING, NULL, {base}_data, " - f"(int){base}_length, NPY_ARRAY_ALIGNED | NPY_ARRAY_WRITEABLE, NULL)" + f"PyArray_New(&PyArray_Type, 0, NULL, {_character_dtype(transfer.semantic_type_name)}, NULL, " + f"{base}_data, {_character_itemsize(f'(int){base}_length', transfer.semantic_type_name)}, " + "NPY_ARRAY_ALIGNED | NPY_ARRAY_WRITEABLE, NULL)" ) return (CDeclaration(target, "PyObject *", CodeExpression(expression)),) @@ -3223,12 +3293,12 @@ def _direct_string_field_getter( length = self._fixed_string_field_length(field) body = ( *self._derived_owner_address_nodes(derived), - CDeclaration(f"value[{length + 1}]", "char"), + CDeclaration(f"value[{length + 1}]", _character_c_type(field.semantic_type_name)), CExpressionStatement( CodeExpression(f"{self._derived_field_bridge_name(derived, field, 'get')}(owner_address, value)") ), - CExpressionStatement(CodeExpression(f"value[{length}] = '\\0'")), - CReturn(CodeExpression(f'PyUnicode_DecodeUTF8(value, {length}, "strict")')), + CExpressionStatement(CodeExpression(_terminator("value", length, field.semantic_type_name))), + CReturn(CodeExpression(_decode_text("value", str(length), field.semantic_type_name))), ) return self._derived_private_method(self._derived_field_method_name(derived, field, "get"), body) @@ -3246,6 +3316,7 @@ def _direct_string_field_setter( CExpressionStatement( CodeExpression(f"{self._derived_field_bridge_name(derived, field, 'set')}(owner_address, value)") ), + *self._text_release_nodes(field.semantic_type_name), CExpressionStatement(CodeExpression("Py_RETURN_NONE")), ) return self._derived_private_method(self._derived_field_method_name(derived, field, "set"), body) @@ -3311,10 +3382,10 @@ def _module_string_member_getter( body = ( CDeclaration("owner_obj", "PyObject *"), CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &owner_obj)) return NULL')), - CDeclaration(f"value[{length + 1}]", "char"), + CDeclaration(f"value[{length + 1}]", _character_c_type(member.field.semantic_type_name)), CExpressionStatement(CodeExpression(f"{self._module_member_bridge_name(variable, member, 'get')}(value)")), - CExpressionStatement(CodeExpression(f"value[{length}] = '\\0'")), - CReturn(CodeExpression(f'PyUnicode_DecodeUTF8(value, {length}, "strict")')), + CExpressionStatement(CodeExpression(_terminator("value", length, member.field.semantic_type_name))), + CReturn(CodeExpression(_decode_text("value", str(length), member.field.semantic_type_name))), ) return self._derived_private_method(self._module_member_method_name(variable, member, "get"), body) @@ -3335,6 +3406,7 @@ def _module_string_member_setter( ), *self._fixed_string_field_input_nodes(field, "value_obj"), CExpressionStatement(CodeExpression(f"{self._module_member_bridge_name(variable, member, 'set')}(value)")), + *self._text_release_nodes(field.semantic_type_name), CExpressionStatement(CodeExpression("Py_RETURN_NONE")), ) return self._derived_private_method(self._module_member_method_name(variable, member, "set"), body) @@ -3350,7 +3422,11 @@ def _fixed_string_field_length(field: DerivedFieldPlan) -> int: def _fixed_string_field_input_nodes(self, field: DerivedFieldPlan, object_name: str) -> tuple: """Require exact UTF-8 byte width and reject embedded NULs.""" return self._text_input_nodes( - object_name, f"field {field.name}", self._fixed_string_field_length(field), "NULL" + object_name, + f"field {field.name}", + self._fixed_string_field_length(field), + "NULL", + field.semantic_type_name, ) def _field_handle_backend_release_nodes(self, field: DerivedFieldPlan, prefix: str) -> tuple: @@ -4279,7 +4355,7 @@ def _field_handle_inquiry_nodes( def _field_native_array_numpy_type(self, field: DerivedFieldPlan) -> str: """Return the NumPy element type one field handle's view is built with.""" if field.string_element: - return "NPY_STRING" + return _character_dtype(field.semantic_type_name) return PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name).array_numpy_type def _field_handle_associate_body( @@ -4775,7 +4851,7 @@ def _module_native_array_inquiry_body( def _module_native_array_numpy_type(self, variable: ModuleVariablePlan) -> str: """Return the NumPy element type one module array's view is built with.""" if variable.datatype_family is DatatypeFamily.STRING: - return "NPY_STRING" + return _character_dtype(variable.semantic_type_name) # A wider logical is exposed with the integer dtype its elements # occupy, which is what the array projection already selected. return PrimitiveScalarTypeRegistry.type_for(variable.semantic_type_name).array_numpy_type @@ -5316,7 +5392,7 @@ def _fortran_owner_binder_function( dispatch = self._owned_native_array_dispatch_name(None, argument) fixed_width = argument.projected_call_slot.character_length dtype_format = "O" if fixed_width is None else "s" - dtype_value = "Py_None" if fixed_width is None else f'"S{fixed_width}"' + dtype_value = "Py_None" if fixed_width is None else f'"{_dtype_code(argument.semantic_type_name)}{fixed_width}"' exposure = ( f'"{handle.extraction_action.value}"' if handle.output_projection is NativeArrayOutputProjection.PROJECTED_HANDLE @@ -5569,8 +5645,9 @@ def _fortran_owner_numpy_body( CExpressionStatement( CodeExpression( "view = PyArray_New(&PyArray_Type, " - f"{handle.array.rank}, dimensions, NPY_STRING, strides, base_address, " - "(int)element_length, NPY_ARRAY_WRITEABLE, NULL)" + f"{handle.array.rank}, dimensions, {_character_dtype(argument.semantic_type_name)}, strides, " + f"base_address, {_character_itemsize('(int)element_length', argument.semantic_type_name)}, " + "NPY_ARRAY_WRITEABLE, NULL)" ) ), CIf(CodeExpression("view == NULL"), body=(CReturn(CodeExpression("NULL")),)), @@ -5598,7 +5675,7 @@ def _fortran_owner_descriptor_inquiry_body( *self._native_array_projection_call_nodes( operation, rank=handle.array.rank, - numpy_type="NPY_STRING", + numpy_type=_character_dtype(argument.semantic_type_name), # A result has no call slot; the completed array facts carry the # same declared width, and 0 means it is deferred in both. element_size=str(self._owner_character_width(argument) or 0), @@ -5821,7 +5898,7 @@ def _owned_native_array_inquiry_body( def _owned_native_array_numpy_type(self, plan: ArgumentTransferPlan | ResultPlan) -> str: """Return the NumPy element type one owned handle's view is built with.""" if plan.datatype_family is DatatypeFamily.STRING: - return "NPY_STRING" + return _character_dtype(plan.semantic_type_name) return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).array_numpy_type def _owned_native_array_operation_handler(self, operation: NativeArrayOperation): @@ -6357,7 +6434,8 @@ def _scalar_storage_view_nodes( """ if character_length is not None: view = ( - f"PyArray_New(&PyArray_Type, 0, NULL, NPY_STRING, NULL, data, {character_length}, " + f"PyArray_New(&PyArray_Type, 0, NULL, {_character_dtype(semantic_type_name)}, NULL, data, " + f"{_character_itemsize(character_length, semantic_type_name)}, " "NPY_ARRAY_ALIGNED | NPY_ARRAY_WRITEABLE, NULL)" ) else: @@ -6407,7 +6485,8 @@ def _scalar_descriptor_view_nodes( that storage. """ if character: - numpy_type, width = "NPY_STRING", "(int)length" + numpy_type = _character_dtype(semantic_type_name) + width = _character_itemsize("(int)length", semantic_type_name) else: numpy_type, width = PrimitiveScalarTypeRegistry.type_for(semantic_type_name).array_numpy_type, "0" arguments = ", ".join((*leading_arguments, *(("&length",) if character else ()))) @@ -6418,7 +6497,7 @@ def _scalar_descriptor_view_nodes( CodeExpression("data == NULL"), body=(CExpressionStatement(CodeExpression("Py_RETURN_NONE")),), ), - *(self._nullable_character_view_width_nodes() if character else ()), + *(self._nullable_character_view_width_nodes(semantic_type_name) if character else ()), CDeclaration( "result", "PyObject *", @@ -6431,7 +6510,7 @@ def _scalar_descriptor_view_nodes( ) @staticmethod - def _nullable_character_view_width_nodes() -> tuple[CIf, ...]: + def _nullable_character_view_width_nodes(semantic_type_name: str) -> tuple[CIf, ...]: """Reject an unrepresentable width and return empty text as a detached value. NumPy has no zero-width bytes dtype, so an allocated empty character @@ -6454,11 +6533,18 @@ def _nullable_character_view_width_nodes() -> tuple[CIf, ...]: body=( CExpressionStatement( CodeExpression( - "PyObject *empty = PyArray_New(&PyArray_Type, 0, NULL, NPY_STRING, NULL, NULL, 1, 0, NULL)" + f"PyObject *empty = PyArray_New(&PyArray_Type, 0, NULL, {_character_dtype(semantic_type_name)}, " + f"NULL, NULL, {_character_itemsize(1, semantic_type_name)}, 0, NULL)" ) ), CIf(CodeExpression("empty == NULL"), body=(CReturn(CodeExpression("NULL")),)), - CExpressionStatement(CodeExpression("((char *)PyArray_DATA((PyArrayObject *)empty))[0] = '\\0'")), + CExpressionStatement( + CodeExpression( + "((char *)PyArray_DATA((PyArrayObject *)empty))[0] = '\\0'" + if character_width(semantic_type_name) == 1 + else "memset(PyArray_DATA((PyArrayObject *)empty), 0, (size_t)PyArray_ITEMSIZE((PyArrayObject *)empty))" + ) + ), CExpressionStatement( CodeExpression("PyArray_CLEARFLAGS((PyArrayObject *)empty, NPY_ARRAY_WRITEABLE)") ), @@ -6483,10 +6569,10 @@ def _lower_module_getter_character_value(self, plan: ModuleVariablePlan) -> tupl "PyObject *", storage="static", body=( - CDeclaration(f"value[{length + 1}]", "char"), + CDeclaration(f"value[{length + 1}]", _character_c_type(plan.semantic_type_name)), CExpressionStatement(CodeExpression(f"{self._module_bridge_getter_name(plan)}(value)")), - CExpressionStatement(CodeExpression(f"value[{length}] = '\\0'")), - CReturn(CodeExpression(f'PyUnicode_DecodeUTF8(value, {length}, "strict")')), + CExpressionStatement(CodeExpression(_terminator("value", length, plan.semantic_type_name))), + CReturn(CodeExpression(_decode_text("value", str(length), plan.semantic_type_name))), ), ), ) @@ -6506,37 +6592,11 @@ def _lower_module_setter_character_value(self, plan: ModuleVariablePlan) -> tupl parameters=(CParameter("value_obj", "PyObject *"),), storage="static", body=( - CIf( - CodeExpression("!PyUnicode_Check(value_obj)"), - body=( - CExpressionStatement( - CodeExpression( - f'PyErr_SetString(PyExc_TypeError, "Expected str for module variable {name}")' - ) - ), - CReturn(CodeExpression("-1")), - ), - ), - CDeclaration("value_length", "Py_ssize_t", CodeExpression("0")), - CDeclaration( - "value", - "const char *", - CodeExpression("PyUnicode_AsUTF8AndSize(value_obj, &value_length)"), - ), - CIf(CodeExpression("value == NULL"), body=(CReturn(CodeExpression("-1")),)), - CIf( - CodeExpression(f"value_length != {length} || (Py_ssize_t)strlen(value) != value_length"), - body=( - CExpressionStatement( - CodeExpression( - f'PyErr_SetString(PyExc_TypeError, "Module variable {name} must encode to ' - f'exactly {length} bytes without embedded NUL")' - ) - ), - CReturn(CodeExpression("-1")), - ), + *self._text_input_nodes( + "value_obj", f"module variable {name}", length, "-1", plan.semantic_type_name ), CExpressionStatement(CodeExpression(f"{self._module_bridge_setter_name(plan)}(value)")), + *self._text_release_nodes(plan.semantic_type_name), CReturn(CodeExpression("0")), ), ), @@ -6631,9 +6691,9 @@ def _lower_module_getter_borrowed_array_view(self, plan: ModuleVariablePlan) -> # a NumPy scalar type macro. character = plan.datatype_family is DatatypeFamily.STRING if character: - element_size = "itemsize" - numpy_type = "NPY_STRING" - numpy_itemsize = "(int)itemsize" + element_size = _character_itemsize("itemsize", plan.semantic_type_name) + numpy_type = _character_dtype(plan.semantic_type_name) + numpy_itemsize = _character_itemsize("(int)itemsize", plan.semantic_type_name) else: scalar = PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name) element_size = f"sizeof({scalar.array_c_spelling})" @@ -7074,9 +7134,11 @@ def _scalar_descriptor_setter_nodes( The bridge reports a disassociated pointer or a character width that differs from the pointer target's, which become Python exceptions here. """ + release: tuple = () if character: - conversion = self._text_input_nodes("value_obj", label, width, failure) + conversion = self._text_input_nodes("value_obj", label, width, failure, semantic_type_name) values = ("value", "(int64_t)value_length") + release = self._text_release_nodes(semantic_type_name) else: conversion = ( CDeclaration("value", PrimitiveScalarTypeRegistry.type_for(semantic_type_name).c_spelling), @@ -7112,24 +7174,56 @@ def _scalar_descriptor_setter_nodes( else () ) call = f"{setter}({', '.join((*leading_arguments, *values))})" - return (*conversion, CDeclaration("status", "int", CodeExpression(call)), *failures) + return (*conversion, CDeclaration("status", "int", CodeExpression(call)), *release, *failures) @staticmethod - def _text_input_nodes(object_name: str, label: str, width: int | None, failure: str) -> tuple: - """Encode one Python string, requiring the declared width when there is one.""" - width_check = f"value_length != {width} || " if width is not None else "" - width_text = f"exactly {width} bytes" if width is not None else "bytes" + def _text_input_nodes( + object_name: str, + label: str, + width: int | None, + failure: str, + semantic_type_name: str, + ) -> tuple: + """Encode one Python str as native characters, requiring the declared width when there is one. + + One-byte characters borrow the str's UTF-8 bytes. Four-byte characters + are converted into a buffer that ``_text_release_nodes`` frees once the + native call has copied them. + """ subject = label[:1].upper() + label[1:] - return ( - CIf( - CodeExpression(f"!PyUnicode_Check({object_name})"), - body=( - CExpressionStatement( - CodeExpression(f'PyErr_SetString(PyExc_TypeError, "Expected str for {label}")') + type_check = CIf( + CodeExpression(f"!PyUnicode_Check({object_name})"), + body=( + CExpressionStatement(CodeExpression(f'PyErr_SetString(PyExc_TypeError, "Expected str for {label}")')), + CReturn(CodeExpression(failure)), + ), + ) + if character_width(semantic_type_name) == 4: + width_check = f"value_length != {width} || " if width is not None else "" + width_text = f"exactly {width} characters" if width is not None else "characters" + return ( + type_check, + CDeclaration("value_length", "Py_ssize_t", CodeExpression(f"PyUnicode_GetLength({object_name})")), + CIf(CodeExpression("value_length < 0"), body=(CReturn(CodeExpression(failure)),)), + CIf( + CodeExpression(f"{width_check}PyUnicode_FindChar({object_name}, 0, 0, value_length, 1) >= 0"), + body=( + CExpressionStatement( + CodeExpression( + f'PyErr_SetString(PyExc_TypeError, "{subject} must contain ' + f'{width_text} without embedded NUL")' + ) + ), + CReturn(CodeExpression(failure)), ), - CReturn(CodeExpression(failure)), ), - ), + CDeclaration("value", "Py_UCS4 *", CodeExpression(f"PyUnicode_AsUCS4Copy({object_name})")), + CIf(CodeExpression("value == NULL"), body=(CReturn(CodeExpression(failure)),)), + ) + width_check = f"value_length != {width} || " if width is not None else "" + width_text = f"exactly {width} bytes" if width is not None else "bytes" + return ( + type_check, CDeclaration("value_length", "Py_ssize_t", CodeExpression("0")), CDeclaration( "value", "const char *", CodeExpression(f"PyUnicode_AsUTF8AndSize({object_name}, &value_length)") @@ -7149,6 +7243,13 @@ def _text_input_nodes(object_name: str, label: str, width: int | None, failure: ), ) + @staticmethod + def _text_release_nodes(semantic_type_name: str) -> tuple: + """Free a converted four-byte character buffer once the native call has used it.""" + if character_width(semantic_type_name) == 4: + return (CExpressionStatement(CodeExpression("PyMem_Free(value)")),) + return () + def _lower_module_setter_reject_replacement(self, _plan: ModuleVariablePlan) -> tuple[CFunction, ...]: """Read-only descriptor rejection is emitted by module attribute routing.""" return () @@ -7998,9 +8099,9 @@ def _lower_argument_required_string_value( ) -> tuple[CDeclaration | CExpressionStatement | CIf, ...]: """Dispatch one completed string input-storage action.""" action = plan.binding.codegen_action - if action is CodegenAction.CALL_LOCAL_INPUT: + if action is CodegenAction.CALL_LOCAL_INPUT and not _uses_character_call_buffer(plan): return self._lower_argument_required_string_input(plan, context) - if action is CodegenAction.COPY_IN_OUT: + if action in {CodegenAction.CALL_LOCAL_INPUT, CodegenAction.COPY_IN_OUT}: return self._lower_argument_required_string_replacement(plan, context) raise ValueError(f"Unsupported required C string action for {plan.owner_path!r}: {action!r}") @@ -8067,7 +8168,8 @@ def _required_string_or_storage_nodes( CodeExpression( f"if (prik_character_input({names.object_name}, {length}, " f"{int(bool(plan.character_allows_embedded_nul))}, {writeable}, " - f'"{plan.binding.python_name}", &{payload_name}, &{names.length_name}) < 0) {{ return NULL; }}' + f'"{plan.binding.python_name}", &{payload_name}, &{names.length_name}, ' + f"{character_width(plan.semantic_type_name)}) < 0) {{ return NULL; }}" ) ), ) @@ -8079,11 +8181,29 @@ def _string_replacement_allocation_nodes( source_name: str, failure_cleanup: tuple[CExpressionStatement, ...], ) -> tuple[CExpressionStatement | CIf, ...]: - """Allocate and copy one validated mutable string payload.""" + """Allocate and fill one validated string call buffer.""" + width = character_width(plan.semantic_type_name) + size = f"(size_t){names.length_name} + 1" if width == 1 else f"((size_t){names.length_name} + 1) * {width}" + fill: tuple = ( + ( + CExpressionStatement( + CodeExpression(f"memcpy({names.value_name}, {source_name}, (size_t){names.length_name})") + ), + CExpressionStatement(CodeExpression(f"{names.value_name}[{names.length_name}] = '\\0'")), + ) + if width == 1 + else ( + CIf( + CodeExpression( + f"prik_character_fill({names.value_name}, {names.object_name}, {source_name}, " + f"{names.length_name}, {width}) < 0" + ), + body=(*failure_cleanup, CReturn(CodeExpression("NULL"))), + ), + ) + ) return ( - CExpressionStatement( - CodeExpression(f"{names.value_name} = (char *)prik_malloc((size_t){names.length_name} + 1)") - ), + CExpressionStatement(CodeExpression(f"{names.value_name} = (char *)prik_malloc({size})")), CIf( CodeExpression(f"{names.value_name} == NULL"), body=( @@ -8097,11 +8217,43 @@ def _string_replacement_allocation_nodes( CReturn(CodeExpression("NULL")), ), ), + *fill, + ) + + @staticmethod + def _required_wide_string_validation_nodes( + plan: ArgumentTransferPlan, + names: _CArgumentNames, + payload_name: str, + ) -> tuple[CExpressionStatement, ...]: + """Measure one str in four-byte characters; the call buffer converts it.""" + nodes = [ + CExpressionStatement( + CodeExpression( + f"if (!PyUnicode_Check({names.object_name})) {{ " + f'PyErr_Format(PyExc_TypeError, "Expected an argument of type str for argument ' + f"{plan.binding.python_name}. Received \", " + f"Py_TYPE({names.object_name})->tp_name); return NULL; }}" + ) + ), CExpressionStatement( - CodeExpression(f"memcpy({names.value_name}, {source_name}, (size_t){names.length_name})") + CodeExpression( + f"if (prik_character_text({names.object_name}, {int(bool(plan.character_allows_embedded_nul))}, " + f'"{plan.binding.python_name}", &{payload_name}, &{names.length_name}, 4) < 0) return NULL' + ) ), - CExpressionStatement(CodeExpression(f"{names.value_name}[{names.length_name}] = '\\0'")), - ) + ] + if plan.character_length is not None: + nodes.append( + CExpressionStatement( + CodeExpression( + f"if ({names.length_name} != {plan.character_length}) {{ " + f'PyErr_SetString(PyExc_TypeError, "Argument {plan.binding.python_name} must contain ' + f'exactly {plan.character_length} characters"); return NULL; }}' + ) + ) + ) + return tuple(nodes) def _required_string_validation_nodes( self, @@ -8109,7 +8261,10 @@ def _required_string_validation_nodes( names: _CArgumentNames, payload_name: str, ) -> tuple[CExpressionStatement, ...]: - """Return shared required-string type, UTF-8, NUL, and length checks.""" + """Return shared required-string type, encoding, NUL, and length checks.""" + width = character_width(plan.semantic_type_name) + if width == 4: + return self._required_wide_string_validation_nodes(plan, names, payload_name) nodes = [ CExpressionStatement( CodeExpression( @@ -8202,12 +8357,16 @@ def _numpy_descriptor_nodes( width_guard = ( CComment("A character dummy is matched on its declared width."), CIf( - CodeExpression(f"PyArray_ITEMSIZE((PyArrayObject *){names.object_name}) != {declared}"), + CodeExpression( + f"PyArray_ITEMSIZE((PyArrayObject *){names.object_name}) != " + f"{_character_itemsize(declared, plan.semantic_type_name)}" + ), body=( CExpressionStatement( CodeExpression( "PyErr_Format(PyExc_TypeError, " - f"\"{plan.binding.python_name} does not match expected dtype dtype('S%d')\", " + f'"{plan.binding.python_name} does not match expected dtype ' + f"dtype('{_dtype_code(plan.semantic_type_name)}%d')\", " f"{declared})" ) ), @@ -9096,6 +9255,8 @@ def _array_dtype_selectors( ) -> tuple[str, str]: """Return compact helper dtype selectors from completed array facts.""" if plan.datatype_family is DatatypeFamily.STRING: + if character_width(plan.semantic_type_name) == 4: + return "NPY_UNICODE", f"numpy.str_[{handoff.itemsize}]" return "NPY_STRING", f"numpy.bytes_[{handoff.itemsize}]" return CBindingGenerator._numeric_array_dtype_selectors(plan) @@ -9236,9 +9397,11 @@ def _array_extraction_nodes( CExpressionStatement(CodeExpression(f"{names.runtime_rank_name} = (int64_t)PyArray_NDIM({array})")) ) if handoff.itemsize_role is not None: - nodes.append( - CExpressionStatement(CodeExpression(f"{names.itemsize_name} = (int64_t)PyArray_ITEMSIZE({array})")) + width = character_width(plan.semantic_type_name) if plan.datatype_family is DatatypeFamily.STRING else 1 + itemsize = ( + f"(int64_t)PyArray_ITEMSIZE({array})" if width == 1 else f"(int64_t)PyArray_ITEMSIZE({array}) / {width}" ) + nodes.append(CExpressionStatement(CodeExpression(f"{names.itemsize_name} = {itemsize}"))) # An assumed width accepts whatever the caller's array declares; only # a stated width is checked against it. if handoff.itemsize is not None: @@ -9485,13 +9648,15 @@ def _lower_argument_required_string_storage( names = context.arguments[plan.owner_path] array = f"(PyArrayObject *){names.object_name}" length = plan.character_length - expected = f"S{length}" if length is not None else "S" + code = _dtype_code(plan.semantic_type_name) + expected = f"{code}{length}" if length is not None else code return ( CDeclaration(names.object_name, "PyObject *"), CDeclaration(names.value_name, "void *", CodeExpression("NULL")), CExpressionStatement( CodeExpression( - f"if (!PyArray_Check({names.object_name}) || PyArray_TYPE({array}) != NPY_STRING || " + f"if (!PyArray_Check({names.object_name}) || " + f"PyArray_TYPE({array}) != {_character_dtype(plan.semantic_type_name)} || " f"PyArray_NDIM({array}) != 0) {{ " f'PyErr_Format(PyExc_TypeError, "Expected a rank-zero numpy.ndarray with dtype {expected} ' f"for argument {plan.binding.python_name}. Received \", " @@ -9502,7 +9667,7 @@ def _lower_argument_required_string_storage( ( CExpressionStatement( CodeExpression( - f"if (PyArray_ITEMSIZE({array}) != {length}) {{ " + f"if (PyArray_ITEMSIZE({array}) != {_character_itemsize(length, plan.semantic_type_name)}) {{ " f'PyErr_SetString(PyExc_TypeError, "Argument {plan.binding.python_name} must use itemsize ' f'{length}"); return NULL; }}' ) @@ -10007,9 +10172,13 @@ def _native_array_dtype_for_semantic_type( semantic_type_name: str, datatype_family: DatatypeFamily, ) -> str | None: - """Translate one completed array element family to a runtime dtype.""" + """Translate one completed array element family to a runtime dtype. + + A character element's width is read from native state; a four-byte + character passes the flexible ``U`` dtype so the width counts code points. + """ if datatype_family is DatatypeFamily.STRING: - return None + return None if character_width(semantic_type_name) == 1 else "U" scalar_type = PrimitiveScalarTypeRegistry.type_for(semantic_type_name) return { "NPY_BOOL": "bool", @@ -10207,7 +10376,7 @@ def _lower_argument_nullable_string_value( CDeclaration(names.length_name, "Py_ssize_t", CodeExpression("0")), ) action = plan.binding.codegen_action - if action is CodegenAction.CALL_LOCAL_INPUT: + if action is CodegenAction.CALL_LOCAL_INPUT and not _uses_character_call_buffer(plan): return ( *declarations, CDeclaration(names.value_name, "const char *", CodeExpression("NULL")), @@ -10220,7 +10389,7 @@ def _lower_argument_nullable_string_value( ), ), ) - if action is CodegenAction.COPY_IN_OUT: + if action in {CodegenAction.CALL_LOCAL_INPUT, CodegenAction.COPY_IN_OUT}: source_name = f"{names.value_name}_source" return ( *declarations, @@ -10366,7 +10535,11 @@ def _lower_result_scalar_descriptor( prior_cleanup = self._decref_names(failure_cleanup) if plan.object_kind is ObjectKind.STRING: conversion = CodeExpression( - f'PyUnicode_DecodeUTF8((const char *){native_name}, (Py_ssize_t){native_name}_length, "strict")' + _decode_text( + f"(const char *){native_name}", f"(Py_ssize_t){native_name}_length", plan.semantic_type_name + ) + if character_width(plan.semantic_type_name) == 1 + else _decode_text(native_name, f"{native_name}_length", plan.semantic_type_name) ) else: scalar_type = PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name) @@ -10712,8 +10885,9 @@ def _array_result_creation_expression( if handoff is None or handoff.itemsize is None or handoff.itemsize <= 0: raise ValueError(f"Character array result {plan.owner_path!r} has no fixed itemsize") return CodeExpression( - f"(PyObject *)PyArray_New(&PyArray_Type, {rank}, {dims_name}, NPY_STRING, " - f"NULL, {native_name}, {handoff.itemsize}, {flags}, NULL)" + f"(PyObject *)PyArray_New(&PyArray_Type, {rank}, {dims_name}, " + f"{_character_dtype(plan.semantic_type_name)}, NULL, {native_name}, " + f"{_character_itemsize(handoff.itemsize, plan.semantic_type_name)}, {flags}, NULL)" ) scalar_type = PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name) if scalar_type.numpy_type_macro is None: @@ -10783,7 +10957,9 @@ def _lower_result_fixed_string( ), else_body=( CExpressionStatement( - CodeExpression(f'{python_name} = Py_BuildValue("s", (const char *){native_name})') + CodeExpression( + f"{python_name} = {_decode_terminated_text(native_name, plan.semantic_type_name)}" + ) ), CExpressionStatement(CodeExpression(f"free({native_name})")), CExpressionStatement(CodeExpression(f"{native_name} = NULL")), @@ -10811,7 +10987,7 @@ def _lower_result_fixed_string( CDeclaration( python_name, "PyObject *", - CodeExpression(f'Py_BuildValue("s", (const char *){native_name})'), + CodeExpression(_decode_terminated_text(native_name, plan.semantic_type_name)), ), CExpressionStatement(CodeExpression(f"free({native_name})")), CExpressionStatement(CodeExpression(f"{native_name} = NULL")), @@ -13368,7 +13544,7 @@ def _mixed_string_writeback_nodes( names = context.arguments[source.owner_path] target = context.python_results[action.owner_path] conversion = CExpressionStatement( - CodeExpression(f'{target} = Py_BuildValue("s", (const char *){names.value_name})') + CodeExpression(f"{target} = {_decode_terminated_text(names.value_name, source.semantic_type_name)}") ) failure = CIf( CodeExpression(f"{target} == NULL"), @@ -13384,7 +13560,7 @@ def _mixed_string_writeback_nodes( converted_value = CExpressionStatement( CodeExpression( f"{target} = prik_character_result({names.object_name}, &{names.value_name}, " - f"(Py_ssize_t){names.length_name})" + f"(Py_ssize_t){names.length_name}, {character_width(source.semantic_type_name)})" ) ) if source.binding.optional_mode is OptionalMode.REQUIRED: @@ -14486,13 +14662,8 @@ def _string_replacement_setup_nodes( @staticmethod def _string_replacement_arguments(plan: FunctionPlan) -> tuple[ArgumentTransferPlan, ...]: - """Return planned binding-owned mutable string buffers.""" - return tuple( - argument - for argument in plan.arguments - if argument.object_kind is ObjectKind.STRING - and argument.binding.codegen_action is CodegenAction.COPY_IN_OUT - ) + """Return the binding-owned string buffers a call fills before it runs.""" + return tuple(argument for argument in plan.arguments if _uses_character_call_buffer(argument)) def _string_replacement_cleanup_nodes( self, @@ -16086,7 +16257,8 @@ def _overload_required_argument_condition( array = f"(PyArrayObject *){value}" predicate = ( f"({predicate} || (PyArray_Check({value}) && PyArray_NDIM({array}) == 0 " - f"&& PyArray_TYPE({array}) == NPY_STRING && PyArray_ITEMSIZE({array}) == {match.character_length}))" + f"&& PyArray_TYPE({array}) == {_character_dtype(match.semantic_type_name)} " + f"&& PyArray_ITEMSIZE({array}) == {_character_itemsize(match.character_length, match.semantic_type_name)}))" ) return predicate if match.kind is OverloadMatchKind.NUMPY_SCALAR: @@ -16766,8 +16938,10 @@ def _module_constant_array_declarations( itemsize_name = f"{value_name}_itemsize" if character: allocation = ( - f"(PyObject *)PyArray_New(&PyArray_Type, {array.rank}, {{dimensions}}, NPY_STRING, " - f"NULL, NULL, (int){itemsize_name}, NPY_ARRAY_F_CONTIGUOUS | NPY_ARRAY_WRITEABLE, NULL)" + f"(PyObject *)PyArray_New(&PyArray_Type, {array.rank}, {{dimensions}}, " + f"{_character_dtype(variable.semantic_type_name)}, NULL, NULL, " + f"{_character_itemsize(f'(int){itemsize_name}', variable.semantic_type_name)}, " + "NPY_ARRAY_F_CONTIGUOUS | NPY_ARRAY_WRITEABLE, NULL)" ) else: scalar_type = PrimitiveScalarTypeRegistry.type_for(variable.semantic_type_name) diff --git a/prik/codegen/docstrings.py b/prik/codegen/docstrings.py index e019fb6b4..00dee46bf 100644 --- a/prik/codegen/docstrings.py +++ b/prik/codegen/docstrings.py @@ -42,7 +42,10 @@ OverloadPlan, ResultPlan, ) -from prik.semantics.scalar_types import BOOLEAN_SEMANTIC_TYPE_NAMES +from prik.semantics.scalar_types import ( + BOOLEAN_SEMANTIC_TYPE_NAMES, + is_string_semantic_type_name, +) _SCALAR_TYPES = { @@ -56,6 +59,7 @@ "Complex64": "complex64", "Complex128": "complex128", "String": "str", + "UString": "str", } # An aliased array reports the width its Fortran elements really occupy. NumPy @@ -513,7 +517,9 @@ def module_variable(self, variable: ModuleVariablePlan) -> str: # A scalar view is a rank-zero array over the module's storage. type_name = variable.semantic_type_name element = ( - "bytes" if type_name == "String" else _ARRAY_ELEMENT_TYPES.get(type_name, self._base_type(variable)) + "bytes" + if is_string_semantic_type_name(type_name) + else _ARRAY_ELEMENT_TYPES.get(type_name, self._base_type(variable)) ) lines = [ f"{name} : ndarray[{element}]" + (" or None" if nullable else ""), @@ -1018,7 +1024,7 @@ def _base_type(self, transfer) -> str: ) return f"{prefix}[{array_element}]" if getattr(transfer, "array", None) is not None: - element = "bytes" if transfer.semantic_type_name == "String" else array_element + element = "bytes" if is_string_semantic_type_name(transfer.semantic_type_name) else array_element return f"ndarray[{self._exact_array_element_label(transfer, element)}]" return scalar diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index a20153d6c..0f4034e11 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -61,7 +61,11 @@ OptionalMode, ScalarLogicalABI, ) -from prik.semantics.scalar_types import is_boolean_semantic_type_name +from prik.semantics.scalar_types import ( + character_width, + is_boolean_semantic_type_name, + is_string_semantic_type_name, +) from prik.codegen.nodes import ( CodeExpression, FortranAllocate, @@ -127,6 +131,36 @@ _SCALAR_DESCRIPTOR_PRESENCE = {AssignmentMode.ALLOCATING_COPY: "allocated", AssignmentMode.TARGET_COPY: "associated"} +# The Fortran kind each character width declares: the one-byte kind every +# default character shares, and UCS-4 for ``ISO_10646``. +_CHARACTER_KINDS = {1: "c_char", 4: "selected_char_kind('ISO_10646')"} + + +def _character_kind(semantic_type_name: str | None) -> str: + """Return the Fortran kind a completed string type declares.""" + if semantic_type_name is None: + raise ValueError("A character declaration has no completed string type") + return _CHARACTER_KINDS[character_width(semantic_type_name)] + + +def _character_bytes(count: str, semantic_type_name: str | None) -> str: + """Return the ``c_size_t`` byte count of ``count`` characters of one string type.""" + width = character_width(semantic_type_name or "") + return count if width == 1 else f"{count} * {width}_c_size_t" + + +def _blank_character(semantic_type_name: str | None) -> str: + """Return one blank character of a string type, the mold a width is repeated from.""" + kind = _character_kind(semantic_type_name) + return "' '" if kind == "c_char" else f"char(32, kind={kind})" + + +def _null_character(semantic_type_name: str | None) -> str: + """Return the terminating NUL character of one string type.""" + kind = _character_kind(semantic_type_name) + return "c_null_char" if kind == "c_char" else f"char(0, kind={kind})" + + @dataclass(frozen=True) class _ScalarDescriptorAccess: """One scalar allocatable or pointer and how a bridge procedure reaches it. @@ -272,7 +306,10 @@ def _require_argument_supported(self, argument: ArgumentTransferPlan) -> None: *((argument.callback.result.transfer,) if argument.callback.result.transfer is not None else ()), ) for transfer in transfers: - if transfer.semantic_type_name != "String" and transfer.derived_type_identity is None: + if ( + not is_string_semantic_type_name(transfer.semantic_type_name) + and transfer.derived_type_identity is None + ): PrimitiveScalarTypeRegistry.type_for(transfer.semantic_type_name) return self._require_backend_type_supported(argument.semantic_type_name, argument.datatype_family) @@ -284,7 +321,7 @@ def _require_variable_supported(self, variable: ModuleVariablePlan) -> None: def _require_derived_type_supported(self, derived: DerivedTypePlan) -> None: """Preflight primitive field types after shared plan validation.""" for field in derived.fields: - if field.semantic_type_name != "String" and field.derived is None: + if not is_string_semantic_type_name(field.semantic_type_name) and field.derived is None: PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name) @staticmethod @@ -535,23 +572,24 @@ def _support_procedure_fortran_type(value: NativeEntrypointABIValuePlan) -> str NativeEntrypointABIValueKind.INT8: "integer(c_int8_t)", NativeEntrypointABIValueKind.INT64: "integer(c_int64_t)", NativeEntrypointABIValueKind.OPAQUE: "type(c_ptr)", - NativeEntrypointABIValueKind.CHARACTER: "character(kind=c_char)", NativeEntrypointABIValueKind.CALLBACK: "type(c_funptr)", } + if value.kind is NativeEntrypointABIValueKind.CHARACTER: + return f"character(kind={_character_kind(value.semantic_type_name)})" if value.kind is NativeEntrypointABIValueKind.DESCRIPTOR: if value.semantic_type_name is None: raise ValueError(f"Generated-support descriptor {value.role!r} has no element type") - if value.semantic_type_name == "String": + if is_string_semantic_type_name(value.semantic_type_name): if value.descriptor_kind is NativeArrayDescriptorKind.POINTER: # A bind(C) pointer character dummy has to declare deferred # length. Pointer assignment takes the length from the # target, so a declared-width array still associates. - return "character(kind=c_char, len=:)" + return f"character(kind={_character_kind(value.semantic_type_name)}, len=:)" # An allocatable descriptor dummy accepts a deferred-length # actual only when it declares one, so the width the array # declares is spelled. length = ":" if value.character_length is None else str(value.character_length) - return f"character(kind=c_char, len={length})" + return f"character(kind={_character_kind(value.semantic_type_name)}, len={length})" return PrimitiveScalarTypeRegistry.type_for(value.semantic_type_name).array_fortran_type try: return types[value.kind] @@ -622,7 +660,7 @@ def _native_array_owner_definitions(self, plan: ModulePlan) -> tuple[FortranType # length is deferred in both. slot = getattr(argument, "projected_call_slot", None) length = slot.character_length if slot is not None else handle.array.itemsize - element_type = f"character(kind=c_char, len={':' if length is None else length})" + element_type = f"character(kind={_character_kind(argument.semantic_type_name)}, len={':' if length is None else length})" definition = FortranTypeDefinition( handle.owner_type_name, ( @@ -806,7 +844,7 @@ def _projected_slot_parameters( if slot.semantic_type_name is None: raise ValueError(f"Projected slot {slot.owner_path!r} has no semantic type") if slot.character_length is not None: - type_name = f"character(kind=c_char, len={slot.character_length})" + type_name = f"character(kind={_character_kind(slot.semantic_type_name)}, len={slot.character_length})" else: type_name = PrimitiveScalarTypeRegistry.type_for(slot.semantic_type_name).fortran_spelling if slot.passing is EntrypointPassingConvention.C_VALUE: @@ -1284,7 +1322,7 @@ def _callback_native_type(self, transfer: CallbackTransferPlan) -> str: raise ValueError(f"Callback derived transfer {transfer.owner_path!r} has no backend symbol") return f"type({self._derived_native_alias(transfer.derived_backend_symbol)})" if transfer.abi is CallbackABIKind.DATA_AND_LENGTH: - return f"character(kind=c_char, len={transfer.character_length})" + return f"character(kind={_character_kind(transfer.semantic_type_name)}, len={transfer.character_length})" return PrimitiveScalarTypeRegistry.type_for(transfer.semantic_type_name).fortran_spelling def _callback_abi_storage_type(self, transfer: CallbackTransferPlan) -> str: @@ -2510,7 +2548,7 @@ def _fortran_owner_allocation_operation( "owner%data", extents, status="status", - type_spec=self._planned_allocation_type_spec(handle), + type_spec=self._planned_allocation_type_spec(handle, argument.semantic_type_name), ), ), ), @@ -3316,7 +3354,7 @@ def _planned_allocation_parameters(handle) -> tuple[FortranParameter, ...]: return (*parameters, FortranParameter("element_length", "integer(c_int64_t)", ("value",))) @staticmethod - def _planned_allocation_type_spec(handle) -> str | None: + def _planned_allocation_type_spec(handle, semantic_type_name: str | None) -> str | None: """Return the type-spec a deferred-length character allocation requires. The width is the planned ``element_length`` argument; the bridge does @@ -3324,7 +3362,7 @@ def _planned_allocation_type_spec(handle) -> str | None: """ if not handle.element_length_argument: return None - return "character(kind=c_char, len=element_length)" + return f"character(kind={_character_kind(semantic_type_name)}, len=element_length)" def _module_native_array_shape_mutation_operation(self, plan: ModuleVariablePlan, operation) -> FortranFunction: """Allocate or resize one module descriptor through completed permissions.""" @@ -3339,7 +3377,9 @@ def _module_native_array_shape_mutation_operation(self, plan: ModuleVariablePlan CodeExpression(self._module_native_array_presence_expression(plan)), body=(FortranDeallocate(native),), ), - FortranAllocate(native, extents, type_spec=self._planned_allocation_type_spec(handle)), + FortranAllocate( + native, extents, type_spec=self._planned_allocation_type_spec(handle, plan.semantic_type_name) + ), ) name = self._module_native_array_operation_name(plan, operation) return FortranFunction( @@ -3418,7 +3458,7 @@ def _module_native_array_element_type(self, plan: ModuleVariablePlan) -> str: """ if plan.datatype_family is DatatypeFamily.STRING: length = ":" if plan.character_length is None else str(plan.character_length) - return f"character(kind=c_char, len={length})" + return f"character(kind={_character_kind(plan.semantic_type_name)}, len={length})" return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).array_fortran_type def _module_pointer_dummy_element_type(self, plan: ModuleVariablePlan) -> str: @@ -3429,7 +3469,7 @@ def _module_pointer_dummy_element_type(self, plan: ModuleVariablePlan) -> str: array that declares its own width still associates through it. """ if plan.datatype_family is DatatypeFamily.STRING: - return "character(kind=c_char, len=:)" + return f"character(kind={_character_kind(plan.semantic_type_name)}, len=:)" return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).array_fortran_type def _module_descriptor_consumer_value_declaration( @@ -3449,7 +3489,7 @@ def _module_descriptor_consumer_value_declaration( if handle is None: raise ValueError(f"Module handle {plan.owner_path!r} has no descriptor policy") if handle.descriptor_attribute is NativeArrayDescriptorAttribute.OTHER: - return "character(kind=c_char, len=*)", (dimension, "intent(inout)") + return f"character(kind={_character_kind(plan.semantic_type_name)}, len=*)", (dimension, "intent(inout)") attribute = handle.descriptor_attribute.value return self._module_native_array_element_type(plan), (attribute, dimension, "intent(inout)") @@ -3573,7 +3613,11 @@ def _lower_module_getter_character_value(self, plan: ModuleVariablePlan) -> tupl FortranFunction( name=name, parameters=( - FortranParameter("value", "character(kind=c_char)", (f"dimension({length})", "intent(out)")), + FortranParameter( + "value", + f"character(kind={_character_kind(plan.semantic_type_name)})", + (f"dimension({length})", "intent(out)"), + ), ), bind_name=name, body=( @@ -3591,7 +3635,11 @@ def _lower_module_setter_character_value(self, plan: ModuleVariablePlan) -> tupl FortranFunction( name=name, parameters=( - FortranParameter("value", "character(kind=c_char)", (f"dimension({length})", "intent(in)")), + FortranParameter( + "value", + f"character(kind={_character_kind(plan.semantic_type_name)})", + (f"dimension({length})", "intent(in)"), + ), ), bind_name=name, body=( @@ -3623,7 +3671,7 @@ def _lower_module_getter_constant_array_value(self, plan: ModuleVariablePlan) -> # declaration spells `len=*` and takes it from an initializer prik does # not evaluate, so the element length is read from the parameter itself. element_type = ( - f"character(kind=c_char, len=len({native}))" + f"character(kind={_character_kind(plan.semantic_type_name)}, len=len({native}))" if character else PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).fortran_spelling ) @@ -3826,7 +3874,13 @@ def _lower_nullable_character_module_getter( result_name="result", result_type="type(c_ptr)", bind_name=name, - declarations=(FortranDeclaration("copy", "character(kind=c_char)", ("pointer", "dimension(:)")),), + declarations=( + FortranDeclaration( + "copy", + f"character(kind={_character_kind(plan.semantic_type_name)})", + ("pointer", "dimension(:)"), + ), + ), body=( FortranAssignment("result", CodeExpression("c_null_ptr")), FortranAssignment("length", CodeExpression("0_c_int64_t")), @@ -3836,7 +3890,9 @@ def _lower_nullable_character_module_getter( FortranAssignment("length", CodeExpression(f"len({native}, kind=c_int64_t)")), FortranAssignment( "result", - CodeExpression("c_malloc(max(1_c_size_t, int(length, c_size_t)))"), + CodeExpression( + f"c_malloc(max(1_c_size_t, {_character_bytes('int(length, c_size_t)', plan.semantic_type_name)}))" + ), ), FortranIf( CodeExpression("c_associated(result)"), @@ -3938,7 +3994,11 @@ def _scalar_descriptor_setter(name: str, access: _ScalarDescriptorAccess) -> For known when Python supplies the value. """ native = access.native - value = CodeExpression("transfer(bytes, repeat(' ', int(length)))" if access.character else "value") + value = CodeExpression( + f"transfer(bytes, repeat({_blank_character(access.semantic_type_name)}, int(length)))" + if access.character + else "value" + ) body: FortranAssignment | FortranIf = FortranAssignment(native, value) if access.presence == "associated": if access.character: @@ -3959,7 +4019,13 @@ def _scalar_descriptor_setter(name: str, access: _ScalarDescriptorAccess) -> For FortranParameter("value", "type(c_ptr)", ("value",)), FortranParameter("length", "integer(c_int64_t)", ("value",)), ) - declarations = (FortranDeclaration("bytes", "character(kind=c_char)", ("pointer", "dimension(:)")),) + declarations = ( + FortranDeclaration( + "bytes", + f"character(kind={_character_kind(access.semantic_type_name)})", + ("pointer", "dimension(:)"), + ), + ) prologue = ( FortranCall( "c_f_pointer", @@ -4234,7 +4300,7 @@ def _lower_argument_native_array_descriptor( def _native_array_argument_element_type(self, plan: ArgumentTransferPlan) -> str: """Return one numeric or deferred-character descriptor dummy type.""" if plan.datatype_family is DatatypeFamily.STRING: - return "character(kind=c_char, len=:)" + return f"character(kind={_character_kind(plan.semantic_type_name)}, len=:)" return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).array_fortran_type def _lower_argument_required(self, plan: ArgumentTransferPlan) -> tuple[FortranParameter, ...]: @@ -4357,7 +4423,7 @@ def _array_descriptor_parameter( if plan.datatype_family is DatatypeFamily.STRING and array.itemsize is None: # The width travels in the descriptor, and a bind(C) character dummy # may not name a variable for it, so it is assumed here. - element_type = "character(kind=c_char, len=*)" + element_type = f"character(kind={_character_kind(plan.semantic_type_name)}, len=*)" attributes = ["dimension(..)" if array.rank is None else self._array_dimension_attribute(array.rank)] if array.contiguous is True: attributes.append("contiguous") @@ -4820,7 +4886,7 @@ def _optional_character_descriptor_parameter( attribute = "allocatable" if descriptor is NativeArrayDescriptorKind.ALLOCATABLE else "pointer" return FortranParameter( name, - f"character(kind=c_char, len={length})", + f"character(kind={_character_kind(argument.semantic_type_name)}, len={length})", (attribute, "optional"), ) @@ -5758,10 +5824,10 @@ def _array_element_fortran_type(self, argument: ArgumentTransferPlan) -> str: if array.itemsize is None: # Every element of the caller's array shares one width, which # the ABI already reports beside the buffer. - return f"character(kind=c_char, len={argument.entrypoint.parameter_name}_itemsize)" + return f"character(kind={_character_kind(argument.semantic_type_name)}, len={argument.entrypoint.parameter_name}_itemsize)" if array.itemsize <= 0: raise ValueError(f"Character array {argument.owner_path!r} has a non-positive itemsize") - return f"character(kind=c_char, len={array.itemsize})" + return f"character(kind={_character_kind(argument.semantic_type_name)}, len={array.itemsize})" return PrimitiveScalarTypeRegistry.type_for(argument.semantic_type_name).array_fortran_type def _array_dimension_attribute(self, rank: int) -> str: @@ -5779,7 +5845,7 @@ def _string_address_declarations(self, plan: FunctionPlan) -> tuple[FortranDecla return tuple( FortranDeclaration( argument.entrypoint.parameter_name, - f"character(kind=c_char, len={self._string_address_length(argument)})", + f"character(kind={_character_kind(argument.semantic_type_name)}, len={self._string_address_length(argument)})", ("pointer",), ) for argument in self._string_address_arguments(plan) @@ -5850,7 +5916,7 @@ def _string_value_declarations(self, plan: FunctionPlan) -> tuple[FortranDeclara ( FortranDeclaration( f"{name}_bytes", - "character(kind=c_char)", + f"character(kind={_character_kind(argument.semantic_type_name)})", ("pointer", "dimension(:)"), ), self._string_value_declaration(argument, name), @@ -5912,7 +5978,7 @@ def _string_value_declaration(cls, plan: ArgumentTransferPlan, name: str) -> For length = str(plan.character_length) else: length = f"{plan.entrypoint.parameter_name}_length" - spelling = f"character(kind=c_char, len={length})" + spelling = f"character(kind={_character_kind(plan.semantic_type_name)}, len={length})" if local.descriptor_kind is None: if cls._string_value_aliases_caller_storage(plan): return FortranDeclaration(name, spelling, ("pointer",)) @@ -5950,7 +6016,7 @@ def _string_value_initializer_nodes( ) # A deferred-length local has no length until it is allocated, so its # mold spells the width instead of naming storage that does not exist. - mold = f"repeat(' ', {name}_length)" if local.deferred_length else name + mold = f"repeat({_blank_character(plan.semantic_type_name)}, {name}_length)" if local.deferred_length else name if self._string_value_aliases_caller_storage(plan): return (FortranCall("c_f_pointer", (CodeExpression(f"bound_{name}"), CodeExpression(name))),) return ( @@ -5984,7 +6050,11 @@ def _character_local_allocation_nodes( if local.descriptor_kind is NativeArrayDescriptorKind.ALLOCATABLE and local.deferred_length: return () if local.deferred_length: - return (FortranAllocate(f"character(kind=c_char, len={name}_length) :: {name}"),) + return ( + FortranAllocate( + f"character(kind={_character_kind(plan.semantic_type_name)}, len={name}_length) :: {name}" + ), + ) return (FortranAllocate(name),) def _character_local_seed_nodes( @@ -6086,7 +6156,9 @@ def _lower_argument_string_copyback( f"{name}_bytes(1:{name}_length)", CodeExpression(f"transfer({name}, {name}_bytes(1:{name}_length))"), ), - FortranAssignment(f"{name}_bytes({name}_length + 1)", CodeExpression("c_null_char")), + FortranAssignment( + f"{name}_bytes({name}_length + 1)", CodeExpression(_null_character(plan.semantic_type_name)) + ), ) def _descriptor_initializers(self, plan: FunctionPlan) -> tuple[FortranPointerAssignment, ...]: @@ -6374,10 +6446,12 @@ def _direct_string_result_declarations(self, result: ResultPlan) -> tuple[Fortra """Declare fixed-string copy storage for one direct result.""" length = self._string_result_length(result) return ( - FortranDeclaration("result_value", f"character(kind=c_char, len={length})"), + FortranDeclaration( + "result_value", f"character(kind={_character_kind(result.semantic_type_name)}, len={length})" + ), FortranDeclaration( "result_copy", - "character(kind=c_char)", + f"character(kind={_character_kind(result.semantic_type_name)})", ("pointer", "dimension(:)"), ), ) @@ -6526,7 +6600,9 @@ def _scalar_descriptor_copy_declarations( attribute = "allocatable" if descriptor.descriptor_kind is NativeArrayDescriptorKind.ALLOCATABLE else "pointer" copy_name = f"{name}_copy" if result.object_kind is ObjectKind.STRING: - copy = FortranDeclaration(copy_name, "character(kind=c_char)", ("pointer", "dimension(:)")) + copy = FortranDeclaration( + copy_name, f"character(kind={_character_kind(result.semantic_type_name)})", ("pointer", "dimension(:)") + ) if value_name is not None: return (copy,) # An allocatable or pointer dummy accepts a deferred-length actual @@ -6534,7 +6610,11 @@ def _scalar_descriptor_copy_declarations( # completed length instead of always deferring it. length = ":" if result.character_length is None else str(result.character_length) return ( - FortranDeclaration(f"{name}_value", f"character(kind=c_char, len={length})", (attribute,)), + FortranDeclaration( + f"{name}_value", + f"character(kind={_character_kind(result.semantic_type_name)}, len={length})", + (attribute,), + ), copy, ) scalar_type = PrimitiveScalarTypeRegistry.type_for(result.semantic_type_name) @@ -6678,6 +6758,7 @@ def _direct_array_result_finalizers( result.array.native_order, result.array.rank, itemsize=self._array_result_itemsize(result), + semantic_type_name=result.semantic_type_name, target_name="result", value_name="result_value", copy_name="result_copy", @@ -6711,6 +6792,7 @@ def _direct_string_result_finalizers( target_name="result", value_name="result_value", copy_name="result_copy", + semantic_type_name=result.semantic_type_name, ) def _owned_direct_native_array_result_finalizers( @@ -6789,7 +6871,7 @@ def _allocatable_character_result_collector(cls, result: ResultPlan) -> FortranF read of storage that was never established. """ length = ":" if result.character_length is None else str(result.character_length) - element_type = f"character(kind=c_char, len={length})" + element_type = f"character(kind={_character_kind(result.semantic_type_name)}, len={length})" return FortranFunction( name=cls._allocatable_character_result_collector_name(), parameters=( @@ -6915,10 +6997,10 @@ def _representation_copy_output_declarations( length = self._string_output_length(slot) value_name = self._native_output_value_name(slot) return ( - FortranDeclaration(value_name, f"character(kind=c_char, len={length})"), + FortranDeclaration(value_name, f"character(kind={_character_kind(slot.semantic_type_name)}, len={length})"), FortranDeclaration( f"{slot.native_name.lower()}_copy", - "character(kind=c_char)", + f"character(kind={_character_kind(slot.semantic_type_name)})", ("pointer", "dimension(:)"), ), ) @@ -7027,7 +7109,10 @@ def _scalar_descriptor_copy_nodes( FortranAssignment(f"{name}_length", CodeExpression(f"len({value_name}, kind=c_int64_t)")), FortranAssignment( name, - CodeExpression(f"c_malloc(max(1_c_size_t, int({name}_length, c_size_t)))"), + CodeExpression( + f"c_malloc(max(1_c_size_t, " + f"{_character_bytes(f'int({name}_length, c_size_t)', result.semantic_type_name)}))" + ), ), FortranIf( CodeExpression(f"c_associated({name})"), @@ -7130,6 +7215,7 @@ def _lower_native_output_representation_copy( slot.array.native_order, slot.array.rank, itemsize=self._array_result_itemsize(slot), + semantic_type_name=slot.semantic_type_name, target_name=name, value_name=f"{name}_value", copy_name=f"{name}_copy", @@ -7145,6 +7231,7 @@ def _lower_native_output_representation_copy( target_name=name, value_name=value_name, copy_name=copy_name, + semantic_type_name=slot.semantic_type_name, ) def _fixed_array_copy_nodes( @@ -7156,6 +7243,7 @@ def _fixed_array_copy_nodes( target_name: str, value_name: str, copy_name: str, + semantic_type_name: str | None, ) -> tuple[FortranAssignment | FortranIf, ...]: """Allocate and fill one detached contiguous ordinary-array copy.""" if rank is None or rank < 0: @@ -7175,6 +7263,7 @@ def _fixed_array_copy_nodes( target_name=target_name, value_name=value_name, copy_name=copy_name, + semantic_type_name=semantic_type_name, ) return ( FortranAssignment( @@ -7242,6 +7331,7 @@ def _fixed_character_array_copy_nodes( target_name: str, value_name: str, copy_name: str, + semantic_type_name: str | None, ) -> tuple[FortranAssignment | FortranIf, ...]: """Allocate one fixed-width character array and copy the value into it. @@ -7257,7 +7347,10 @@ def _fixed_character_array_copy_nodes( return ( FortranAssignment( target_name, - CodeExpression(f"c_malloc(max(1_c_size_t, {itemsize}_c_size_t * size({value_name}, kind=c_size_t)))"), + CodeExpression( + "c_malloc(max(1_c_size_t, " + f"{_character_bytes(f'{itemsize}_c_size_t', semantic_type_name)} * size({value_name}, kind=c_size_t)))" + ), ), FortranIf( CodeExpression(f"c_associated({target_name})"), @@ -7285,11 +7378,11 @@ def _array_result_element_type( """Return the completed numeric, fixed, or deferred character element type.""" if plan.datatype_family is DatatypeFamily.STRING: if plan.native_array_handle is not None and plan.array is not None and plan.array.itemsize is None: - return "character(kind=c_char, len=:)" + return f"character(kind={_character_kind(plan.semantic_type_name)}, len=:)" itemsize = self._array_result_itemsize(plan) if itemsize is None: raise ValueError(f"Character array result {plan.owner_path!r} has no itemsize") - return f"character(kind=c_char, len={itemsize})" + return f"character(kind={_character_kind(plan.semantic_type_name)}, len={itemsize})" if plan.semantic_type_name is None: raise ValueError(f"Array result {plan.owner_path!r} has no element type") return PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).array_fortran_type @@ -7313,11 +7406,14 @@ def _fixed_string_copy_nodes( target_name: str, value_name: str, copy_name: str, + semantic_type_name: str | None, ) -> tuple[FortranAssignment | FortranIf, ...]: """Allocate and fill one justified NUL-terminated fixed string copy.""" c_length = length + 1 return ( - FortranAssignment(target_name, CodeExpression(f"c_malloc({c_length}_c_size_t)")), + FortranAssignment( + target_name, CodeExpression(f"c_malloc({_character_bytes(f'{c_length}_c_size_t', semantic_type_name)})") + ), FortranIf( CodeExpression(f"c_associated({target_name})"), body=( @@ -7333,7 +7429,7 @@ def _fixed_string_copy_nodes( f"{copy_name}(1:{length})", CodeExpression(f"transfer({value_name}, {copy_name}(1:{length}))"), ), - FortranAssignment(f"{copy_name}({c_length})", CodeExpression("c_null_char")), + FortranAssignment(f"{copy_name}({c_length})", CodeExpression(_null_character(semantic_type_name))), ), ), ) @@ -7978,7 +8074,7 @@ def _direct_string_field_getter( FortranParameter("owner_address", "type(c_ptr)", ("value",)), FortranParameter( "value", - "character(kind=c_char)", + f"character(kind={_character_kind(field.semantic_type_name)})", (f"dimension({length})", "intent(out)"), ), ), @@ -8007,7 +8103,7 @@ def _direct_string_field_setter( FortranParameter("owner_address", "type(c_ptr)", ("value",)), FortranParameter( "value", - "character(kind=c_char)", + f"character(kind={_character_kind(field.semantic_type_name)})", (f"dimension({length})", "intent(in)"), ), ), @@ -8037,7 +8133,7 @@ def _module_string_member_getter( parameters=( FortranParameter( "value", - "character(kind=c_char)", + f"character(kind={_character_kind(member.field.semantic_type_name)})", (f"dimension({length})", "intent(out)"), ), ), @@ -8063,7 +8159,7 @@ def _module_string_member_setter( parameters=( FortranParameter( "value", - "character(kind=c_char)", + f"character(kind={_character_kind(field.semantic_type_name)})", (f"dimension({length})", "intent(in)"), ), ), @@ -8297,7 +8393,7 @@ def _native_handle_field_resize_procedure(self, owner, field, operation) -> Fort FortranAllocate( expression, tuple(CodeExpression(f"extent_{axis}") for axis in range(handle.array.rank)), - type_spec=self._planned_allocation_type_spec(handle), + type_spec=self._planned_allocation_type_spec(handle, field.semantic_type_name), ), ), is_subroutine=True, @@ -8309,7 +8405,7 @@ def _native_handle_field_associate_procedure(self, owner, field) -> FortranFunct if handle is None or handle.array.rank is None: raise ValueError(f"Pointer field {field.owner_path!r} has no association rank") element_type = ( - "character(kind=c_char, len=:)" + f"character(kind={_character_kind(field.semantic_type_name)}, len=:)" if field.string_element else PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name).array_fortran_type ) @@ -9148,10 +9244,10 @@ def _procedure_prototype_type( 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": + if is_string_semantic_type_name(value.semantic_type_name): if value.character_length is None: raise ValueError(f"Prototype value {value.owner_path!r} has no fixed character length") - return f"character(kind=c_char, len={value.character_length})" + return f"character(kind={_character_kind(value.semantic_type_name)}, len={value.character_length})" return PrimitiveScalarTypeRegistry.type_for(value.semantic_type_name).fortran_spelling @staticmethod @@ -9199,7 +9295,7 @@ def _procedure_prototype_import( """Return the host symbol needed to spell one prototype value type.""" if value.derived_backend_symbol is not None: return self._derived_native_alias(value.derived_backend_symbol) - if value.semantic_type_name == "String": + if is_string_semantic_type_name(value.semantic_type_name): return "c_char" return self._iso_symbol(value.semantic_type_name) @@ -9412,7 +9508,7 @@ def _native_array_owner_callback_interfaces(self, plan: ModulePlan) -> tuple[For parameters=( FortranParameter( "value", - f"character(kind=c_char, len={length})", + f"character(kind={_character_kind(argument.semantic_type_name)}, len={length})", attributes, ), FortranParameter("context", "type(c_ptr)", ("value",)), @@ -9463,11 +9559,11 @@ def _native_handle_callback_interface( if handle is None or handle.array.rank is None: raise ValueError(f"Native handle field {field.owner_path!r} has no callback rank") if handle.descriptor_attribute is NativeArrayDescriptorAttribute.OTHER: - element_type = "character(kind=c_char, len=*)" + element_type = f"character(kind={_character_kind(field.semantic_type_name)}, len=*)" attributes = (self._array_dimension_attribute(handle.array.rank), "intent(inout)") else: element_type = ( - "character(kind=c_char, len=:)" + f"character(kind={_character_kind(field.semantic_type_name)}, len=:)" if field.string_element else PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name).array_fortran_type ) @@ -9689,7 +9785,11 @@ def _external_interface_scalar_descriptor_result_parameter( raise ValueError(f"Scalar descriptor output {slot.owner_path!r} has no descriptor plan") attribute = "allocatable" if descriptor.descriptor_kind is NativeArrayDescriptorKind.ALLOCATABLE else "pointer" if slot.object_kind is ObjectKind.STRING: - return FortranParameter(slot.native_name.lower(), "character(kind=c_char, len=:)", (attribute,)) + return FortranParameter( + slot.native_name.lower(), + f"character(kind={_character_kind(slot.semantic_type_name)}, len=:)", + (attribute,), + ) if slot.semantic_type_name is None: raise ValueError(f"Scalar descriptor output {slot.owner_path!r} has no element type") scalar_type = PrimitiveScalarTypeRegistry.type_for(slot.semantic_type_name) @@ -9711,7 +9811,9 @@ def _external_interface_string_result_parameter( ) -> FortranParameter: """Declare one completed fixed or assumed-length string output.""" length = "*" if slot.character_length is None else str(slot.character_length) - return FortranParameter(slot.native_name.lower(), f"character(kind=c_char, len={length})") + return FortranParameter( + slot.native_name.lower(), f"character(kind={_character_kind(slot.semantic_type_name)}, len={length})" + ) def _external_interface_array_result_parameter( self, @@ -9783,7 +9885,7 @@ def _native_result_type(self, plan: FunctionPlan, result: ResultPlan | None) -> else "pointer" ) if result.object_kind is ObjectKind.STRING: - return f"character(kind=c_char, len=:), {attribute}" + return f"character(kind={_character_kind(result.semantic_type_name)}, len=:), {attribute}" scalar_type = PrimitiveScalarTypeRegistry.type_for(result.semantic_type_name) return f"{scalar_type.fortran_spelling}, {attribute}" if result.object_kind is ObjectKind.NUMPY_ARRAY: @@ -9792,7 +9894,7 @@ def _native_result_type(self, plan: FunctionPlan, result: ResultPlan | None) -> shape = self._array_result_shape(plan, result) return f"{self._array_result_element_type(result)}, dimension({', '.join(shape)})" if result.object_kind is ObjectKind.STRING: - return f"character(kind=c_char, len={self._string_result_length(result)})" + return f"character(kind={_character_kind(result.semantic_type_name)}, len={self._string_result_length(result)})" if result.object_kind is ObjectKind.DERIVED_TYPE: if result.derived is None: raise ValueError(f"Derived result {result.owner_path!r} has no handoff plan") @@ -9840,7 +9942,7 @@ def _external_interface_parameter( length_text = "*" if length is None else str(length) return FortranParameter( parameter_name, - f"character(kind=c_char, len={length_text})", + f"character(kind={_character_kind(argument.semantic_type_name)}, len={length_text})", attributes, ) return FortranParameter( @@ -10066,6 +10168,7 @@ def _iso_symbol(self, semantic_type_name: str) -> str: "Complex128": "c_double_complex", "Complex256": "c_long_double_complex", "String": "c_char", + "UString": "c_char", } return symbols[semantic_type_name] diff --git a/prik/codegen/primitive_scalar_types.py b/prik/codegen/primitive_scalar_types.py index 85fafde52..e91063f45 100644 --- a/prik/codegen/primitive_scalar_types.py +++ b/prik/codegen/primitive_scalar_types.py @@ -105,6 +105,7 @@ class NumpyDtypeRegistry: "Int64": "numpy.int64", "SizeT": "numpy.uintp", "String": "numpy.str_", + "UString": "numpy.str_", "UInt8": "numpy.uint8", "UInt16": "numpy.uint16", "UInt32": "numpy.uint32", diff --git a/prik/contracts/__init__.py b/prik/contracts/__init__.py index f22e8364c..b103b0ec2 100644 --- a/prik/contracts/__init__.py +++ b/prik/contracts/__init__.py @@ -52,6 +52,10 @@ class _ContractType(metaclass=_ContractTypeMeta): """Base for semantic contract types.""" +# NumPy dtype code for fixed-width character storage of each string contract. +_CHARACTER_DTYPE_CODES: Final[dict[str, str]] = {"String": "S", "UString": "U"} + + class _ArrayContract: """Runtime description retained by a subscripted contract type.""" @@ -70,12 +74,14 @@ def __init__( self.character_array = character_array def __getitem__(self, item: object) -> _ArrayContract | _ContractExpression: - if self.element_type.__name__ == "String" and not self.character_array: + if self.element_type.__name__ in _CHARACTER_DTYPE_CODES and not self.character_array: length = self.shape if isinstance(length, int) and not isinstance(length, bool) and length > 0: - dtype = np.dtype(f"S{length}") + dtype = np.dtype(f"{_CHARACTER_DTYPE_CODES[self.element_type.__name__]}{length}") elif isinstance(length, slice) and length == slice(None): - dtype = None + # A deferred width reads from native state; ``U`` keeps its kind. + code = _CHARACTER_DTYPE_CODES[self.element_type.__name__] + dtype = None if code == "S" else np.dtype(code) else: raise TypeError("character array contracts require a positive integer width or ':'") return _ArrayContract( @@ -216,6 +222,7 @@ def apply(target): ) SizeT = _contract_type("SizeT", _CONTRACT_NUMPY_FACTORIES["SizeT"]) String = _contract_type("String", constructor_error="String requires an explicit native length and encoding contract") +UString = _contract_type("UString", constructor_error="UString requires an explicit native length contract") UInt = _contract_type("UInt", constructor_error="UInt requires a resolved native width") UInt8 = _contract_type("UInt8", _CONTRACT_NUMPY_FACTORIES["UInt8"]) UInt16 = _contract_type("UInt16", _CONTRACT_NUMPY_FACTORIES["UInt16"]) @@ -413,6 +420,7 @@ def destroy(target): "SizeT", "SourceName", "String", + "UString", "Transfer", "UInt", "UInt8", @@ -480,6 +488,7 @@ def destroy(target): "Returns", "SizeT", "String", + "UString", "UInt", "UInt8", "UInt16", diff --git a/prik/pipeline/type_mapping_report.py b/prik/pipeline/type_mapping_report.py index 286ee3c08..a1c130c47 100644 --- a/prik/pipeline/type_mapping_report.py +++ b/prik/pipeline/type_mapping_report.py @@ -18,6 +18,7 @@ from dataclasses import asdict import platform +from prik.semantics.scalar_types import is_string_semantic_type_name from prik.codegen.primitive_scalar_types import NumpyDtypeRegistry from prik.parsers.c.models import ( CBool, @@ -358,7 +359,7 @@ def _numpy_dtype(semantic_dtype: str | None) -> str: expression = NumpyDtypeRegistry.expression_for(semantic_dtype) except KeyError: return "unsupported" - if semantic_dtype == "String": + if is_string_semantic_type_name(semantic_dtype): return f"{expression} / ABI bytes" return expression diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index f3eacfb1c..639f1cd70 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -20,6 +20,7 @@ from pathlib import Path import time +from prik.semantics.scalar_types import is_string_semantic_type_name from prik.utilities.declaration_expressions import RUNTIME_DIMENSION_MARKERS from prik.utilities.stage_values import StageRecord from prik.policy.ownership import ( @@ -5494,7 +5495,7 @@ def _one_string_writeback_action_diagnostics( if ( action.codegen_action is CodegenAction.COPY_IN_OUT and action.object_kind is ObjectKind.STRING - and action.semantic_type_name == "String" + and is_string_semantic_type_name(action.semantic_type_name) and action.datatype_family is DatatypeFamily.STRING and action.result_position == argument.result_position ): diff --git a/prik/planning/entrypoints.py b/prik/planning/entrypoints.py index a746256aa..f16231558 100644 --- a/prik/planning/entrypoints.py +++ b/prik/planning/entrypoints.py @@ -11,6 +11,7 @@ import re from dataclasses import dataclass +from prik.semantics.scalar_types import is_string_semantic_type_name from prik.naming.native_symbols import NativeSymbolNames from prik.policy.models import ( CallbackABIKind, @@ -346,7 +347,7 @@ def _descriptor_parameter( pointer_depth=1, semantic_type_name=semantic_type_name, rank=handle.array.rank, - character_length=handle.array.itemsize if semantic_type_name == "String" else None, + character_length=handle.array.itemsize if is_string_semantic_type_name(semantic_type_name) else None, descriptor_kind=handle.descriptor_kind, intent=intent, ) @@ -723,6 +724,7 @@ def _scalar_storage_field_operations(self, owner, field, route, owner_path, owne pointer_depth=1, const=True, character_length=field.character_length, + semantic_type_name=field.semantic_type_name, intent="in", ) if field.string_element @@ -771,6 +773,7 @@ def _string_field_operations(self, owner, field, route, owner_path, owner_parame NativeEntrypointABIValueKind.CHARACTER, pointer_depth=1, character_length=field.character_length, + semantic_type_name=field.semantic_type_name, intent="out", ) operations = [ @@ -788,6 +791,7 @@ def _string_field_operations(self, owner, field, route, owner_path, owner_parame pointer_depth=1, const=True, character_length=field.character_length, + semantic_type_name=field.semantic_type_name, intent="in", ) operations.append( @@ -1220,6 +1224,7 @@ def _primary_module_variable_operations(self, variable): NativeEntrypointABIValueKind.CHARACTER, pointer_depth=1, character_length=variable.character_length, + semantic_type_name=variable.semantic_type_name, intent="out", ), ) @@ -1261,6 +1266,7 @@ def _module_setter_signature(self, variable): pointer_depth=1, const=True, character_length=variable.character_length, + semantic_type_name=variable.semantic_type_name, intent="in", ) return (value,), None diff --git a/prik/planning/planner.py b/prik/planning/planner.py index 755318b65..808db3190 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -150,7 +150,10 @@ TransformationPlan, ) from prik.naming.native_symbols import NativeSymbolNames -from prik.semantics.scalar_types import BOOLEAN_SEMANTIC_TYPE_NAMES +from prik.semantics.scalar_types import ( + BOOLEAN_SEMANTIC_TYPE_NAMES, + is_string_semantic_type_name, +) from prik.utilities.visitor import ClassVisitor from prik.planning.entrypoints import ( @@ -246,6 +249,7 @@ def requires_cfi_header(namespaces: tuple[NamespacePlan, ...]) -> bool: "Complex128": DatatypeFamily.COMPLEX, "Complex256": DatatypeFamily.COMPLEX, "String": DatatypeFamily.STRING, + "UString": DatatypeFamily.STRING, "AnyNative": DatatypeFamily.ASSUMED_NATIVE, } @@ -1722,7 +1726,7 @@ def _entrypoint_result_plan_from_slot( raise ValueError(f"Hidden entrypoint result {slot.owner_path!r} has incomplete type facts") character_capacity = ( slot.character_length - if direct_c_abi and slot.semantic_type_name == "String" and slot.character_length + if direct_c_abi and is_string_semantic_type_name(slot.semantic_type_name) and slot.character_length else None ) return NativeEntrypointResultPlan( diff --git a/prik/policy/completion.py b/prik/policy/completion.py index 36b13b32a..0e402fec6 100644 --- a/prik/policy/completion.py +++ b/prik/policy/completion.py @@ -14,7 +14,10 @@ import re from collections.abc import Iterable -from prik.semantics.scalar_types import SEMANTIC_SCALAR_TYPE_NAMES +from prik.semantics.scalar_types import ( + SEMANTIC_SCALAR_TYPE_NAMES, + is_string_semantic_type_name, +) from prik.utilities.declaration_expressions import declaration_extent_references from prik.policy.ownership import ( CodegenAction, @@ -1169,7 +1172,7 @@ def _complete_native_status_error_policy(function: models.SemanticFunction, owne message = None if message_name is not None: message = _native_status_output(function, owner_path, message_name, subject="message", allow_visible=True) - if message.rank != 0 or message.semantic_type_name != "String": + if message.rank != 0 or not is_string_semantic_type_name(message.semantic_type_name): raise ValueError( f"Function {function.name!r} raises message target {message.name!r} " "must be a scalar string hidden output or visible argument" @@ -1577,7 +1580,7 @@ def _requires_fortran_array_owner( owner address or a live descriptor. Module variables and fields retain their existing native entity. """ - return semantic_type.name == "String" and (context.is_argument or context.is_result) + return is_string_semantic_type_name(semantic_type.name) and (context.is_argument or context.is_result) def _native_array_owner_signature(descriptor_kind: str, semantic_type: models.SemanticType) -> int: @@ -1842,7 +1845,10 @@ def _handle_releases_its_own_storage(handle_kind: str, context: OwnershipContext def _is_deferred_character_array(semantic_type: models.SemanticType) -> bool: """Return whether shape mutation also requires a runtime character length.""" - return semantic_type.name == "String" and semantic_type.metadata.get("fortran_character_length") == ":" + return ( + is_string_semantic_type_name(semantic_type.name) + and semantic_type.metadata.get("fortran_character_length") == ":" + ) def _native_array_descriptor_interop_requirement( @@ -1879,7 +1885,10 @@ def _native_array_descriptor_attribute( ordinary assumed-shape descriptor. The native entity remains allocatable or pointer; only the callback projection has the ``other`` attribute. """ - fixed_character = semantic_type.name == "String" and declared_character_length(semantic_type.metadata) is not None + fixed_character = ( + is_string_semantic_type_name(semantic_type.name) + and declared_character_length(semantic_type.metadata) is not None + ) if fixed_character and ( fortran_owner or handle_kind in {"borrowed_module_descriptor", "borrowed_field_descriptor"} ): @@ -2038,7 +2047,7 @@ def _is_primitive_scalar_value( allow_completed_projection: bool = False, ) -> bool: """Report whether a type is a plain scalar value or an allowed completed address projection.""" - if semantic_type.rank != 0 or semantic_type.name == "String": + if semantic_type.rank != 0 or is_string_semantic_type_name(semantic_type.name): return False if (semantic_type.dtype or semantic_type.name) not in SEMANTIC_SCALAR_TYPE_NAMES: return False @@ -2060,7 +2069,7 @@ def _is_visible_extent_source(semantic_type: models.SemanticType) -> bool: storage = semantic_type.storage return bool( semantic_type.rank == 0 - and semantic_type.name != "String" + and not is_string_semantic_type_name(semantic_type.name) and (semantic_type.dtype or semantic_type.name) in SEMANTIC_SCALAR_TYPE_NAMES and storage is not None and storage.array is not None @@ -2102,7 +2111,7 @@ def _validate_raw_address_type( "rank and shape using literals or visible scalar arguments." ) return - if semantic_type.name == "String": + if is_string_semantic_type_name(semantic_type.name): length = semantic_type.metadata.get("fortran_character_length") if length is None or not _is_resolved_extent(length, visible_scalar_names): raise ValueError( @@ -2288,7 +2297,7 @@ def _callback_argument_ownership_context(argument: models.SemanticArgument) -> O def _validate_callback_argument_contract(argument: models.SemanticArgument) -> None: """Require reference callback strings to use mutable scalar character storage.""" semantic_type = argument.semantic_type - if semantic_type.name != "String": + if not is_string_semantic_type_name(semantic_type.name): return if bool(getattr(argument.origin, "metadata", {}).get("value")): return diff --git a/prik/policy/construction.py b/prik/policy/construction.py index 4a90c3ebc..e065c38a8 100644 --- a/prik/policy/construction.py +++ b/prik/policy/construction.py @@ -180,8 +180,11 @@ ) from prik.semantics.scalar_types import ( BOOLEAN_SEMANTIC_TYPE_NAMES, + STRING_SEMANTIC_TYPE_NAMES, + character_width, is_boolean_semantic_type_name, is_integer_semantic_type_name, + is_string_semantic_type_name, ) @@ -365,7 +368,7 @@ def build_derived_field_policy( name=field.name, native_name=str(field.origin.native_name or field.name), semantic_type_name=field.semantic_type.name, - string_element=field.semantic_type.name == "String", + string_element=is_string_semantic_type_name(field.semantic_type.name), rank=int(field.semantic_type.rank or 0), object_kind=getter.kind, access=access, @@ -922,15 +925,15 @@ def _derived_field_object_kind_blockers( rank = int(semantic_type.rank or 0) blockers: list[str] = [] if access is DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW: - if semantic_type.name not in _PLAN_PRIMITIVE_SCALAR_TYPES | {"String"}: + if semantic_type.name not in _PLAN_PRIMITIVE_SCALAR_TYPES | STRING_SEMANTIC_TYPE_NAMES: blockers.append(f"field {field.name!r} is not a primitive or character scalar descriptor") elif access is DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW: - if semantic_type.name not in _PLAN_PRIMITIVE_SCALAR_TYPES | {"String"}: + if semantic_type.name not in _PLAN_PRIMITIVE_SCALAR_TYPES | STRING_SEMANTIC_TYPE_NAMES: blockers.append(f"field {field.name!r} is not a primitive or character stored scalar") - elif semantic_type.name == "String" and _character_length(semantic_type) is None: + elif is_string_semantic_type_name(semantic_type.name) and _character_length(semantic_type) is None: blockers.append(f"field {field.name!r} is not a fixed scalar string") elif getter.kind is ObjectKind.NUMPY_ARRAY: - if semantic_type.name not in _PLAN_PRIMITIVE_SCALAR_TYPES | {"String"}: + if semantic_type.name not in _PLAN_PRIMITIVE_SCALAR_TYPES | STRING_SEMANTIC_TYPE_NAMES: blockers.append(f"field {field.name!r} is an unsupported array of derived values") elif getter.kind is ObjectKind.DERIVED_TYPE: if rank != 0: @@ -1257,7 +1260,7 @@ def _constant_array_module_variable_blockers( blockers.append("module parameter array is not public") if array is None or array.rank is None or array.rank <= 0 or len(array.shape) != array.rank: blockers.append("module parameter array requires one concrete fixed rank") - if variable.semantic_type.name not in _PLAN_PRIMITIVE_SCALAR_TYPES | {"String"}: + if variable.semantic_type.name not in _PLAN_PRIMITIVE_SCALAR_TYPES | STRING_SEMANTIC_TYPE_NAMES: blockers.append("module parameter array requires a primitive numeric element type") expected_getter = ( getter is not None @@ -1335,7 +1338,7 @@ def _ordinary_array_module_variable_blockers( blockers = [] if array.rank is None or array.rank <= 0 or len(array.shape) != array.rank: blockers.append("ordinary module array requires one concrete fixed rank") - if variable.semantic_type.name not in _PLAN_PRIMITIVE_SCALAR_TYPES | {"String"}: + if variable.semantic_type.name not in _PLAN_PRIMITIVE_SCALAR_TYPES | STRING_SEMANTIC_TYPE_NAMES: blockers.append("ordinary module array requires a primitive numeric element type") expected_getter = ( ("owner", getter.owner, OwnershipOwner.NATIVE), @@ -1596,7 +1599,7 @@ def _callback_abi_kind( semantic_type = argument.semantic_type if derived: return CallbackABIKind.DERIVED_ADDRESS - if semantic_type.name == "String": + if is_string_semantic_type_name(semantic_type.name): return CallbackABIKind.DATA_AND_LENGTH if int(semantic_type.rank or 0) > 0: return CallbackABIKind.DATA_AND_SHAPE @@ -1658,7 +1661,7 @@ def _callback_transfer_blockers( 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 is_string_semantic_type_name(semantic_type.name): 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") elif transfer.rank > 0: @@ -2028,7 +2031,7 @@ def _normalize_c_direct_scalar_identities( # A C payload is bytes plus whatever length the contract passes. # Refusing an embedded NUL would impose a terminator convention # that belongs to the C author, not to PRIK. - character_allows_embedded_nul=argument.semantic_type_name == "String", + character_allows_embedded_nul=is_string_semantic_type_name(argument.semantic_type_name), ) for argument in arguments ] @@ -2087,7 +2090,7 @@ def _complete_entrypoint_argument_route( # declared width instead of the adapter assuming it. or ( argument.handoff_mode is ArgumentHandoffMode.OPAQUE_ADDRESS - and argument.semantic_type_name == "String" + and is_string_semantic_type_name(argument.semantic_type_name) ) ) ), @@ -2432,7 +2435,7 @@ def _direct_c_operation_ineligibility( else: reasons.extend(_direct_argument_ineligibility(argument)) for result in results: - if result.semantic_type_name == "String": + if is_string_semantic_type_name(result.semantic_type_name): # Only argument character contracts are adopted. A projected string # result would need the owned-allocation protocol the Fortran # adapter provides, and C has no adapter to allocate it. @@ -2452,7 +2455,7 @@ def _direct_c_operation_ineligibility( def _is_c_string_argument(argument: ArgumentPolicy) -> bool: """Return whether one completed C argument carries a character contract.""" - return argument.semantic_type_name == "String" + return is_string_semantic_type_name(argument.semantic_type_name) def _direct_c_string_ineligibility(argument: ArgumentPolicy) -> tuple[str, ...]: @@ -2720,7 +2723,7 @@ def _direct_c_abi_type_policy( converts_to_contract_storage: bool | None = None, ) -> DirectCABITypePolicy: """Normalize preserved source facts or the canonical source-free C form.""" - if semantic_type_name == "String": + if is_string_semantic_type_name(semantic_type_name): return _direct_c_character_abi_type_policy(source, semantic_type=semantic_type, writes_output=writes_output) scalar_name = _c_direct_scalar_name(semantic_type) or semantic_type_name if scalar_name is None: @@ -2979,7 +2982,7 @@ def _direct_slot_ineligibility( reasons.append(f"native-call slot {slot.native_position} has no binding projection action") if slot.entrypoint_passing is EntrypointPassingConvention.BLOCKED: reasons.append(f"native-call slot {slot.native_position} has no C passing convention") - character_slot = slot.semantic_type_name == "String" and ( + character_slot = is_string_semantic_type_name(slot.semantic_type_name) and ( character_representation_is_binding_owned or slot.character_length == 1 ) if slot.bridge_data_action is BridgeDataAction.COPY_REPRESENTATION and not character_slot: @@ -3017,7 +3020,7 @@ def _direct_descriptor_supported(argument: ArgumentPolicy) -> bool: and handle.handoff.abi is NativeDescriptorHandoffABI.DIRECT_STANDARD_DESCRIPTOR and argument.handoff_mode is ArgumentHandoffMode.NATIVE_DESCRIPTOR and argument.entrypoint_passing is EntrypointPassingConvention.C_DESCRIPTOR_POINTER - and argument.semantic_type_name in {*_PLAN_PRIMITIVE_SCALAR_TYPES, "String"} + and argument.semantic_type_name in {*_PLAN_PRIMITIVE_SCALAR_TYPES, *STRING_SEMANTIC_TYPE_NAMES} and argument.rank > 0 and argument.derived is None and not argument.transformations @@ -3063,7 +3066,7 @@ def _result_requires_explicit_interface(result: ResultPolicy) -> bool: return False if result.scalar_descriptor is not None or result.native_array_handle is not None or result.derived is not None: return True - if result.rank > 0 or result.semantic_type_name == "String": + if result.rank > 0 or is_string_semantic_type_name(result.semantic_type_name): return True return _array_requires_explicit_interface(result.array) @@ -5846,7 +5849,7 @@ def _scalar_descriptor_result_blockers( blockers = [] if decision.is_blocked: blockers.append(f"{label} has blocked ownership policy: {decision.blocker or decision.reason}") - expected_kind = ObjectKind.STRING if semantic_type.name == "String" else ObjectKind.SCALAR + expected_kind = ObjectKind.STRING if is_string_semantic_type_name(semantic_type.name) else ObjectKind.SCALAR if decision.kind is not expected_kind: blockers.append(f"{label} policy kind is {decision.kind.value}, not {expected_kind.value}") if decision.owner is not OwnershipOwner.PYTHON: @@ -6163,7 +6166,7 @@ def _character_local_policy( attribute, and a ``pointer`` local is adapter-allocated storage the adapter must also release. """ - if int(semantic_type.rank or 0) != 0 or semantic_type.name != "String": + if int(semantic_type.rank or 0) != 0 or not is_string_semantic_type_name(semantic_type.name): return None plain = CharacterLocalPolicy( descriptor_kind=None, @@ -6216,7 +6219,7 @@ def _character_descriptor_blockers( ``character(len=:)`` is not a declarable local without it. """ semantic_type = argument.semantic_type - if int(semantic_type.rank or 0) != 0 or semantic_type.name != "String": + if int(semantic_type.rank or 0) != 0 or not is_string_semantic_type_name(semantic_type.name): return () descriptor = character_descriptor_kind(semantic_type.metadata) deferred = _has_deferred_character_length(semantic_type) @@ -6321,7 +6324,7 @@ def _is_first_lane_scalar_type(semantic_type: models.SemanticType) -> bool: return bool( int(semantic_type.rank or 0) == 0 and not _is_scalar_storage_type(semantic_type) - and semantic_type.name != "String" + and not is_string_semantic_type_name(semantic_type.name) and _is_plan_primitive_value_type(semantic_type) ) @@ -6380,7 +6383,7 @@ def _is_scalar_storage_type(semantic_type: models.SemanticType) -> bool: def _is_plan_string_value_type(semantic_type: models.SemanticType) -> bool: """Return whether one semantic type is a scalar Python string value.""" - return bool(int(semantic_type.rank or 0) == 0 and semantic_type.name == "String") + return bool(int(semantic_type.rank or 0) == 0 and is_string_semantic_type_name(semantic_type.name)) def _is_fixed_plan_string_result_type(semantic_type: models.SemanticType) -> bool: @@ -6450,7 +6453,7 @@ def _scalar_descriptor_result_policy( return None return ScalarDescriptorResultPolicy( descriptor_kind=NativeArrayDescriptorKind(descriptor), - runtime_length=semantic_type.name == "String", + runtime_length=is_string_semantic_type_name(semantic_type.name), nullable=decision.nullable, copy_reason=SCALAR_DESCRIPTOR_RESULT_COPY_REASON, release_owner=OwnershipOwner.PYTHON, @@ -6502,7 +6505,7 @@ def _native_array_handle_wrapper_policy( operations.add(NativeArrayOperation.SHAPE) if descriptor == "pointer" and descriptor_inquiries: operations.update({NativeArrayOperation.CONTIGUOUS, NativeArrayOperation.DESCRIPTOR}) - if semantic_type.name == "String": + if is_string_semantic_type_name(semantic_type.name): operations.add(NativeArrayOperation.ELEMENT_LENGTH) if completed.destroy_behavior == NativeArrayDestroyBehavior.HANDLE_FINALIZER.value: operations.add(NativeArrayOperation.DESTROY) @@ -6753,7 +6756,7 @@ def _array_replacement_transformations( ) -> tuple[tuple[TransformationPolicy, ...], tuple[str, ...]]: """Copy immutable storage once and publish the mutated temporary as output.""" blockers = [] - if argument.optional or array.rank is None or argument.semantic_type.name == "String": + if argument.optional or array.rank is None or is_string_semantic_type_name(argument.semantic_type.name): blockers.append(f"argument {argument.name!r} array replacement requires a required numeric fixed rank") if decision.codegen_action is not CodegenAction.COPY_IN_OUT or not decision.projects_result: blockers.append(f"argument {argument.name!r} array replacement has incomplete copy-out policy") @@ -6807,7 +6810,7 @@ def _copy_to_fortran_argument_blockers( blockers.append(f"argument {argument.name!r} COPY_F requires ordinary NumPy array storage") if decision.descriptor_boundary or native_array_descriptor_kind(argument.semantic_type) is not None: blockers.append(f"argument {argument.name!r} COPY_F does not support native descriptors") - if argument.semantic_type.name == "String": + if is_string_semantic_type_name(argument.semantic_type.name): blockers.append(f"argument {argument.name!r} COPY_F character arrays are not implemented") if array.rank is None or array.rank <= 1 or array.contiguous is not True: blockers.append(f"argument {argument.name!r} COPY_F requires a concrete dense multidimensional array") @@ -6828,9 +6831,10 @@ def _native_array_actual_dtype(argument: models.SemanticArgument) -> str | None: because a handle whose elements are a different length describes different storage. An assumed width is read from the live descriptor. """ - if argument.semantic_type.name == "String": + if is_string_semantic_type_name(argument.semantic_type.name): length = _character_length(argument.semantic_type) - return "S" if length is None else f"S{length}" + code = "U" if character_width(argument.semantic_type.name) == 4 else "S" + return code if length is None else f"{code}{length}" return _NUMPY_DTYPE_NAMES.get(argument.semantic_type.name) @@ -7081,10 +7085,10 @@ def _scalar_module_getter_blockers( literal_string = _is_binding_literal_string(variable, getter_action) character_value = getter_action in {ModuleGetterAction.CHARACTER_VALUE, ModuleGetterAction.NATIVE_CHARACTER_VIEW} # A descriptor character getter reports its current width with the address. - character_descriptor = ( - getter_action in {ModuleGetterAction.NULLABLE_SNAPSHOT, ModuleGetterAction.NATIVE_NULLABLE_SCALAR_VIEW} - and variable.semantic_type.name == "String" - ) + character_descriptor = getter_action in { + ModuleGetterAction.NULLABLE_SNAPSHOT, + ModuleGetterAction.NATIVE_NULLABLE_SCALAR_VIEW, + } and is_string_semantic_type_name(variable.semantic_type.name) string_getter = literal_string or character_value or character_descriptor if not (_is_first_lane_scalar_type(variable.semantic_type) or string_getter): blockers.append("module variable is not a primitive rank-zero scalar") @@ -7109,7 +7113,9 @@ def _scalar_module_getter_blockers( blockers.append("module variable getter is not a supported scalar policy") elif getter.codegen_action not in supported_getter_actions: blockers.append(f"module variable getter action {getter.codegen_action.value!r} is unsupported") - if getter_action is ModuleGetterAction.NATIVE_SCALAR_VIEW and variable.semantic_type.name == "String": + if getter_action is ModuleGetterAction.NATIVE_SCALAR_VIEW and is_string_semantic_type_name( + variable.semantic_type.name + ): blockers.append("native scalar storage view requires a primitive numeric type") return tuple(blockers) @@ -7121,7 +7127,7 @@ def _is_binding_literal_string( """Return whether the binding materializes one rank-zero string literal.""" return bool( getter_action is ModuleGetterAction.CONSTANT_VALUE - and variable.semantic_type.name == "String" + and is_string_semantic_type_name(variable.semantic_type.name) and int(variable.semantic_type.rank or 0) == 0 ) @@ -7232,7 +7238,7 @@ def _is_fixed_length_character_scalar(variable: models.SemanticVariable) -> bool """Return whether one module variable is a rank-zero declared-length character.""" semantic_type = variable.semantic_type return bool( - semantic_type.name == "String" + is_string_semantic_type_name(semantic_type.name) and int(semantic_type.rank or 0) == 0 and _character_length(semantic_type) is not None and character_descriptor_kind(semantic_type.metadata) is None @@ -7302,7 +7308,7 @@ def _scalar_module_literal_value(value: object, semantic_type_name: str) -> obje return True if lowered in {".false.", "false"}: return False - if semantic_type_name == "String": + if is_string_semantic_type_name(semantic_type_name): # 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) @@ -7629,7 +7635,8 @@ def _is_scalar_derived_type(semantic_type: models.SemanticType) -> bool: """Return whether semantic facts name a concrete rank-zero custom type.""" return bool( int(semantic_type.rank or 0) == 0 - and semantic_type.name not in {"String", "Void", "AnyNative"} + and not is_string_semantic_type_name(semantic_type.name) + and semantic_type.name not in {"Void", "AnyNative"} and not _is_plan_primitive_value_type(semantic_type) and semantic_type.name not in {"Procedure", "Callback", "FunctionPointer", "CFunctionPointer"} ) @@ -7647,7 +7654,8 @@ def _is_derived_value_array(semantic_type: models.SemanticType) -> bool: """Return whether an array contains custom derived values rather than primitives.""" return bool( int(semantic_type.rank or 0) > 0 - and semantic_type.name not in {"String", "AnyNative"} + and not is_string_semantic_type_name(semantic_type.name) + and semantic_type.name != "AnyNative" and not _is_plan_primitive_value_type(semantic_type) ) @@ -7671,7 +7679,7 @@ def _native_result_bridge_data_action( return BridgeDataAction.DIRECT_TRANSFER, None if _is_phase6_ordinary_array_type(semantic_type): return BridgeDataAction.COPY_REPRESENTATION, ORDINARY_ARRAY_RESULT_COPY_REASON - if semantic_type.name == "String" and _character_length(semantic_type) is not None: + if is_string_semantic_type_name(semantic_type.name) and _character_length(semantic_type) is not None: return ( BridgeDataAction.COPY_REPRESENTATION, FIXED_STRING_RESULT_COPY_REASON, @@ -7727,7 +7735,7 @@ def _array_handoff_policy( array = storage.array if storage is not None else None if array is None: return None - if semantic_type.name == "String" and array.category == SCALAR_STORAGE_CATEGORY: + if is_string_semantic_type_name(semantic_type.name) and array.category == SCALAR_STORAGE_CATEGORY: return None runtime_rank = array.category in {"assumed_rank", "runtime_rank"} rank = _array_handoff_rank(semantic_type, array.rank, runtime_rank) @@ -7742,7 +7750,7 @@ def _array_handoff_policy( order = _array_handoff_order(array.order, array.category) entrypoint_abi = _array_entrypoint_abi( array.category, - character=semantic_type.name == "String", + character=is_string_semantic_type_name(semantic_type.name), source_language=source_language, ) contiguous = _array_handoff_contiguous(array.contiguous, array.category, entrypoint_abi) @@ -7762,7 +7770,7 @@ def _array_handoff_policy( flatten_python_storage=flatten_python_storage, flat_axis=_array_handoff_flat_axis(array), itemsize=_array_handoff_itemsize(semantic_type), - character=semantic_type.name == "String", + character=is_string_semantic_type_name(semantic_type.name), category=array.category, extent_references=tuple(declaration_extent_references(item) for item in shape), ) @@ -7931,7 +7939,7 @@ def _array_handoff_flat_axis(array: models.SemanticArrayContract) -> int | None: def _array_handoff_itemsize(semantic_type: models.SemanticType) -> int | None: """Carry fixed character width only for string array elements.""" - if semantic_type.name == "String": + if is_string_semantic_type_name(semantic_type.name): return _character_length(semantic_type) return None @@ -7949,7 +7957,7 @@ def _is_phase6_ordinary_array_type(semantic_type: models.SemanticType) -> bool: # A character array may leave its width assumed: every element of a NumPy # ``S`` array shares one itemsize, which already travels beside the buffer. supported_element = _is_plan_primitive_value_type(semantic_type) or ( - semantic_type.name == "String" and not scalar_storage + is_string_semantic_type_name(semantic_type.name) and not scalar_storage ) supported_rank = array_policy.rank is None or 1 <= array_policy.rank <= 15 or scalar_storage return bool( @@ -7980,7 +7988,7 @@ def _is_phase6_raw_array_address_type(semantic_type: models.SemanticType) -> boo if len(policy.shape) != policy.rank or len(policy.axes) != policy.rank: return False supported_element = _is_plan_primitive_value_type(semantic_type) or ( - semantic_type.name == "String" and policy.itemsize is not None + is_string_semantic_type_name(semantic_type.name) and policy.itemsize is not None ) return supported_element and all(item not in RUNTIME_DIMENSION_MARKERS for item in policy.shape) @@ -8016,8 +8024,8 @@ def _raw_array_handoff_policy(semantic_type: models.SemanticType) -> ArrayHandof signed_strides=False, minimum_rank=rank, maximum_rank=rank, - itemsize=_character_length(semantic_type) if semantic_type.name == "String" else None, - character=semantic_type.name == "String", + itemsize=_character_length(semantic_type) if is_string_semantic_type_name(semantic_type.name) else None, + character=is_string_semantic_type_name(semantic_type.name), category="raw_address", extent_references=tuple(declaration_extent_references(item) for item in shape), ) diff --git a/prik/policy/ownership.py b/prik/policy/ownership.py index 4151d9a7b..0e6d3bda6 100644 --- a/prik/policy/ownership.py +++ b/prik/policy/ownership.py @@ -52,7 +52,10 @@ ) from prik.semantics.models import PYTHON_VALUE_IMMUTABLE, PYTHON_VALUE_MUTABILITY_METADATA from prik.semantics.ownership_metadata import OWNERSHIP_POLICY_METADATA, POINTER_POLICY_METADATA -from prik.semantics.scalar_types import BOOLEAN_SEMANTIC_TYPE_NAMES +from prik.semantics.scalar_types import ( + BOOLEAN_SEMANTIC_TYPE_NAMES, + is_string_semantic_type_name, +) # Completed policy vocabulary @@ -2643,7 +2646,7 @@ def _semantic_facts(semantic_type: Any) -> _StorageFacts: storage_metadata = getattr(storage, "metadata", {}) if storage is not None else {} name = str(getattr(semantic_type, "name", "")) rank = int(getattr(semantic_type, "rank", 0) or 0) - is_string = name == "String" + is_string = is_string_semantic_type_name(name) is_custom = rank == 0 and not is_string and name not in _STANDARD_SCALAR_TYPES | {"AnyNative"} return _StorageFacts( rank=rank, diff --git a/prik/preprocessing/probes/fortran_types.py b/prik/preprocessing/probes/fortran_types.py index da299065a..b2dfec463 100644 --- a/prik/preprocessing/probes/fortran_types.py +++ b/prik/preprocessing/probes/fortran_types.py @@ -51,6 +51,10 @@ ) _SAFE_EXPRESSION_RE = re.compile(r"^[A-Za-z0-9_+\-*/().,= :]+$") _TOKEN_RE = re.compile(r"\b[A-Za-z_][A-Za-z0-9_]*\b") +# A quoted name such as ``'ISO_10646'`` in ``selected_char_kind('ISO_10646')``. +# It holds only word characters, so removing it before validation cannot hide +# a statement, and the name it spells is data rather than an identifier. +_QUOTED_NAME_RE = re.compile(r"'[A-Za-z0-9_]+'") _PROBE_INTRINSIC_NAMES = frozenset( { @@ -239,7 +243,7 @@ def probe_can_resolve_expression(expression: str) -> bool: program that cannot resolve it. """ known = _PROBE_INTRINSIC_NAMES | _ISO_FORTRAN_ENV_NAMES | _ISO_C_BINDING_NAMES - return all(token.lower() in known for token in _TOKEN_RE.findall(expression)) + return all(token.lower() in known for token in _TOKEN_RE.findall(_QUOTED_NAME_RE.sub("", expression))) def build_fortran_type_probe_source(expressions: Sequence[str]) -> str: @@ -314,7 +318,7 @@ def _validate_expression(expression: str) -> None: raise FortranTypeProbeError( f"Fortran type probe expression is not a single initialization expression: {expression!r}" ) - if _SAFE_EXPRESSION_RE.fullmatch(expression) is None: + if _SAFE_EXPRESSION_RE.fullmatch(_QUOTED_NAME_RE.sub("0", expression)) is None: raise FortranTypeProbeError(f"Fortran type probe expression contains unsupported characters: {expression!r}") @@ -325,7 +329,9 @@ def _probe_import_lines(expressions: Sequence[str]) -> list[str]: import for each supported intrinsic module. Names are sorted so generated source and cache keys stay deterministic. """ - tokens = {token.lower() for expression in expressions for token in _TOKEN_RE.findall(expression)} + tokens = { + token.lower() for expression in expressions for token in _TOKEN_RE.findall(_QUOTED_NAME_RE.sub("", expression)) + } lines: list[str] = [] env_names = sorted(tokens & _ISO_FORTRAN_ENV_NAMES) c_names = sorted(tokens & _ISO_C_BINDING_NAMES) diff --git a/prik/printers/pyi.py b/prik/printers/pyi.py index f46c97cdd..e72de8347 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.utilities.declaration_expressions import fortran_character_value, outside_character_literals -from prik.semantics.scalar_types import SEMANTIC_SCALAR_TYPE_NAMES +from prik.semantics.scalar_types import SEMANTIC_SCALAR_TYPE_NAMES, is_string_semantic_type_name from prik.semantics.ownership_metadata import ( OWNERSHIP_POLICY_METADATA, POINTER_POLICY_FIELDS, @@ -635,7 +635,7 @@ def _semantic_base_type( ``String`` shorthand; when a shape subscription follows, the length slot is always spelled so the two are never confused. """ - if semantic_type.name != "String": + if not is_string_semantic_type_name(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 @@ -644,7 +644,7 @@ def _semantic_base_type( return str(completed) return context.contract_type(str(semantic_type.name)) length = semantic_type.metadata.get("fortran_character_length") - string = context.contract("String") + string = context.contract(str(semantic_type.name)) if length is None or str(length) in {"", "*"}: return f"{string}[...]" if shape_follows else string if str(length) == ":": @@ -665,7 +665,7 @@ def _is_normal_storage_address( and storage.pointer_depth == 1 and storage.metadata.get(ADDRESS_ROLE_METADATA) != ADDRESS_ROLE_RAW and ( - semantic_type.name == "String" + is_string_semantic_type_name(semantic_type.name) or str(semantic_type.name) in context.semantic_class_names or semantic_type.metadata.get(_WRAPPED_CALLABLE_TYPE_METADATA) ) @@ -967,7 +967,7 @@ def _prototype_argument_inner_type( """Return the native prototype dummy type without transport wrappers.""" storage = semantic_type.storage if ( - semantic_type.name == "String" + is_string_semantic_type_name(semantic_type.name) and storage is not None and storage.array is not None and storage.array.category == SCALAR_STORAGE_CATEGORY @@ -1000,7 +1000,8 @@ def _is_prototype_primitive_value(semantic_type: SemanticType) -> bool: storage = semantic_type.storage return bool( semantic_type.rank == 0 - and semantic_type.name not in {"String", "Void"} + and semantic_type.name != "Void" + and not is_string_semantic_type_name(semantic_type.name) and (semantic_type.dtype or semantic_type.name) in SEMANTIC_SCALAR_TYPE_NAMES and (storage is None or storage.kind == "value") and not PyiPrinter._is_prototype_descriptor_type(semantic_type) @@ -1016,7 +1017,8 @@ def _is_prototype_primitive_reference(semantic_type: SemanticType) -> bool: storage = semantic_type.storage return bool( semantic_type.rank == 0 - and semantic_type.name not in {"String", "Void"} + and semantic_type.name != "Void" + and not is_string_semantic_type_name(semantic_type.name) and (semantic_type.dtype or semantic_type.name) in SEMANTIC_SCALAR_TYPE_NAMES and storage is not None and storage.kind in {"reference", "address", "pointer"} @@ -1174,7 +1176,7 @@ def _is_scalar_address_projection(semantic_type: SemanticType) -> bool: storage = semantic_type.storage return bool( semantic_type.rank == 0 - and semantic_type.name != "String" + and not is_string_semantic_type_name(semantic_type.name) and not semantic_type.metadata.get("fortran_allocatable") and not semantic_type.metadata.get("fortran_pointer") and semantic_type.dtype in SEMANTIC_SCALAR_TYPE_NAMES @@ -1488,7 +1490,7 @@ def _constructor_accepts_field(field: SemanticVariable) -> bool: return ( field.visibility == "public" and semantic_type.rank == 0 - and semantic_type.name != "String" + and not is_string_semantic_type_name(semantic_type.name) and semantic_type.name in NumpyDtypeRegistry.TYPES and not semantic_type.metadata.get("fortran_allocatable") and not semantic_type.metadata.get("fortran_pointer") @@ -1856,7 +1858,7 @@ def _visible_wrapped_callable_type(semantic_type: SemanticType) -> SemanticType: storage = semantic_type.storage if not ( semantic_type.rank == 0 - and semantic_type.name != "String" + and not is_string_semantic_type_name(semantic_type.name) and (semantic_type.dtype or semantic_type.name) not in SEMANTIC_SCALAR_TYPE_NAMES and storage is not None and storage.kind in {"reference", "pointer", "address"} diff --git a/prik/runtime/handles.py b/prik/runtime/handles.py index 331de231d..ecd428fd7 100644 --- a/prik/runtime/handles.py +++ b/prik/runtime/handles.py @@ -378,7 +378,13 @@ def __init__( raise ValueError( f"native array handle to_numpy_policy must be one of {sorted(self._VALID_TO_NUMPY_POLICIES)!r}" ) - self._dtype = None if dtype is None else np.dtype(dtype) + # A flexible character dtype (``"S"`` or ``"U"``, no width) names a + # deferred-length character array: the width is read from native state + # and the letter says whether it holds bytes or UCS-4 code points. + declared = None if dtype is None else np.dtype(dtype) + deferred = declared is None or (declared.kind in "SU" and declared.itemsize == 0) + self._character_code = declared.kind if declared is not None and deferred else "S" + self._dtype = None if deferred else declared self._rank = int(rank) if not callable(invoke): raise TypeError(f"native array handle dispatcher must be callable; received {type(invoke).__name__}") @@ -443,6 +449,9 @@ def _deferred_character_dtype(self) -> np.dtype: length = operator.index(self._call_operation("element_length")) if length < 0: raise ValueError("native character array element length must be non-negative") + # The native element length counts bytes; a UCS-4 character takes four. + if self._character_code == "U": + return np.dtype(f"U{length // 4}") return np.dtype(f"S{length}") @property diff --git a/prik/runtime/native_support/prik_binding.h b/prik/runtime/native_support/prik_binding.h index e325bb5e7..2dc9c24bd 100644 --- a/prik/runtime/native_support/prik_binding.h +++ b/prik/runtime/native_support/prik_binding.h @@ -1143,22 +1143,58 @@ PRIK_NO_INLINE PRIK_MAYBE_UNUSED static int prik_rank_zero_storage( } /* - * Borrow the rank-zero fixed-width bytes an ndarray passes for one character + * Decode ``length`` characters of native storage into a Python str. + * + * character_width is 1 for the default kind, whose bytes are UTF-8, or 4 for + * UCS-4 (``ISO_10646``), whose code points are native-order 32-bit values. + */ +PRIK_MAYBE_UNUSED static PyObject *prik_character_decode(const void *data, Py_ssize_t length, int character_width) +{ + if (character_width == 4) { + return PyUnicode_FromKindAndData(PyUnicode_4BYTE_KIND, data, length); + } + return PyUnicode_DecodeUTF8((const char *)data, length, "strict"); +} + +/* Decode one NUL-terminated native character copy into a Python str. */ +PRIK_MAYBE_UNUSED static PyObject *prik_character_decode_terminated(const void *data, int character_width) +{ + Py_ssize_t length = 0; + if (character_width == 4) { + while (((const Py_UCS4 *)data)[length] != 0) { + length += 1; + } + return prik_character_decode(data, length, 4); + } + return Py_BuildValue("s", (const char *)data); +} + +/* + * Borrow the rank-zero fixed-width storage an ndarray passes for one character * argument. * - * value must be an ndarray. Returns 0 with *data set to its bytes, or -1 with - * a TypeError set when it is not rank-zero S storage. + * value must be an ndarray. Returns 0 with *data set to its storage, or -1 + * with a TypeError set when it is not rank-zero S storage, or + * U storage for a four-byte character width. */ PRIK_NO_INLINE PRIK_MAYBE_UNUSED static int prik_rank_zero_bytes( PyObject *value, Py_ssize_t width, int require_writeable, const char *argument_name, - const char **data) + const char **data, + int character_width) { PyArrayObject *array = (PyArrayObject *)value; - if (PyArray_TYPE(array) != NPY_STRING || PyArray_NDIM(array) != 0 || PyArray_ITEMSIZE(array) != width) { - PyErr_Format(PyExc_TypeError, "Argument %s requires rank-zero S%zd storage", argument_name, width); + int dtype = character_width == 4 ? NPY_UNICODE : NPY_STRING; + if (PyArray_TYPE(array) != dtype || PyArray_NDIM(array) != 0 + || PyArray_ITEMSIZE(array) != width * character_width) { + PyErr_Format( + PyExc_TypeError, + "Argument %s requires rank-zero %c%zd storage", + argument_name, + character_width == 4 ? 'U' : 'S', + width); return -1; } if (!PyArray_ISALIGNED(array)) { @@ -1174,12 +1210,74 @@ PRIK_NO_INLINE PRIK_MAYBE_UNUSED static int prik_rank_zero_bytes( } /* - * Take one fixed-width character argument that also accepts rank-zero bytes. + * Measure one Python str in the characters of a native character width. * - * An ndarray must be rank-zero S storage, and *source then points at - * its bytes. Any other value must be a str whose UTF-8 encoding is exactly - * width bytes, without embedded NUL unless allow_embedded_nul is set. - * *length receives the byte count. Returns 0, or -1 with an exception set. + * A one-byte width counts UTF-8 bytes and borrows them through *source; a + * four-byte width counts code points and leaves *source NULL, because the + * str holds no UCS-4 storage to lend -- prik_character_fill copies it. + * Returns 0, or -1 with an exception set. + */ +PRIK_NO_INLINE PRIK_MAYBE_UNUSED static int prik_character_text( + PyObject *value, + int allow_embedded_nul, + const char *argument_name, + const char **source, + Py_ssize_t *length, + int character_width) +{ + if (character_width == 4) { + *source = NULL; + *length = PyUnicode_GetLength(value); + if (*length < 0) { + return -1; + } + if (!allow_embedded_nul && PyUnicode_FindChar(value, 0, 0, *length, 1) >= 0) { + PyErr_Format(PyExc_TypeError, "Argument %s cannot contain embedded NUL", argument_name); + return -1; + } + return 0; + } + *source = PyUnicode_AsUTF8AndSize(value, length); + if (*source == NULL) { + return -1; + } + if (!allow_embedded_nul && (Py_ssize_t)strlen(*source) != *length) { + PyErr_Format(PyExc_TypeError, "Argument %s cannot contain embedded NUL", argument_name); + return -1; + } + return 0; +} + +/* + * Fill a call buffer of length + 1 characters from one measured str. + * + * The one-byte width copies the borrowed UTF-8 bytes; the four-byte width + * converts the str's code points. Both terminate the buffer with a NUL + * character. Returns 0, or -1 with an exception set. + */ +PRIK_MAYBE_UNUSED static int prik_character_fill( + char *buffer, + PyObject *value, + const char *source, + Py_ssize_t length, + int character_width) +{ + if (character_width == 4) { + return PyUnicode_AsUCS4(value, (Py_UCS4 *)buffer, length + 1, 1) == NULL ? -1 : 0; + } + memcpy(buffer, source, (size_t)length); + buffer[length] = '\0'; + return 0; +} + +/* + * Take one fixed-width character argument that also accepts rank-zero storage. + * + * An ndarray must be rank-zero S storage, or U for a four-byte + * character width, and *source then points at it. Any other value must be a + * str of exactly width characters of that width (UTF-8 bytes for the default + * kind), without embedded NUL unless allow_embedded_nul is set. *length + * receives the character count. Returns 0, or -1 with an exception set. */ PRIK_NO_INLINE PRIK_MAYBE_UNUSED static int prik_character_input( PyObject *value, @@ -1188,10 +1286,11 @@ PRIK_NO_INLINE PRIK_MAYBE_UNUSED static int prik_character_input( int require_writeable, const char *argument_name, const char **source, - Py_ssize_t *length) + Py_ssize_t *length, + int character_width) { if (PyArray_Check(value)) { - if (prik_rank_zero_bytes(value, width, require_writeable, argument_name, source) < 0) { + if (prik_rank_zero_bytes(value, width, require_writeable, argument_name, source, character_width) < 0) { return -1; } *length = width; @@ -1205,16 +1304,16 @@ PRIK_NO_INLINE PRIK_MAYBE_UNUSED static int prik_character_input( Py_TYPE(value)->tp_name); return -1; } - *source = PyUnicode_AsUTF8AndSize(value, length); - if (*source == NULL) { - return -1; - } - if (!allow_embedded_nul && (Py_ssize_t)strlen(*source) != *length) { - PyErr_Format(PyExc_TypeError, "Argument %s cannot contain embedded NUL", argument_name); + if (prik_character_text(value, allow_embedded_nul, argument_name, source, length, character_width) < 0) { return -1; } if (*length != width) { - PyErr_Format(PyExc_TypeError, "Argument %s must encode to exactly %zd bytes", argument_name, width); + PyErr_Format( + PyExc_TypeError, + character_width == 4 ? "Argument %s must contain exactly %zd characters" + : "Argument %s must encode to exactly %zd bytes", + argument_name, + width); return -1; } return 0; @@ -1226,13 +1325,17 @@ PRIK_NO_INLINE PRIK_MAYBE_UNUSED static int prik_character_input( * Rank-zero bytes storage was updated in place, so its bytes are decoded. A * str was copied into *buffer, which is converted and then released. */ -PRIK_NO_INLINE PRIK_MAYBE_UNUSED static PyObject *prik_character_result(PyObject *value, char **buffer, Py_ssize_t length) +PRIK_NO_INLINE PRIK_MAYBE_UNUSED static PyObject *prik_character_result( + PyObject *value, + char **buffer, + Py_ssize_t length, + int character_width) { PyObject *result; if (PyArray_Check(value)) { - return PyUnicode_DecodeUTF8((const char *)*buffer, length, "strict"); + return prik_character_decode(*buffer, length, character_width); } - result = Py_BuildValue("s", (const char *)*buffer); + result = character_width == 4 ? prik_character_decode(*buffer, length, 4) : Py_BuildValue("s", (const char *)*buffer); free(*buffer); *buffer = NULL; return result; diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 7af33d4a2..cc4add1de 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -65,6 +65,7 @@ BOOLEAN_STORAGE_BITS, SEMANTIC_SCALAR_TYPE_NAMES, is_boolean_semantic_type_name, + is_string_semantic_type_name, ) from prik.utilities.visitor import ClassVisitor @@ -166,6 +167,11 @@ ("character", None): "String", ("character", "1"): "String", ("character", "c_char"): "String", + ("character", "selected_char_kind('default')"): "String", + ("character", "selected_char_kind('ascii')"): "String", + ("character", "selected_char_kind('iso_10646')"): "UString", + # Every compiler with a four-byte character kind numbers UCS-4 as kind 4. + ("character", "4"): "UString", } _FORTRAN_INTRINSIC_TYPES = frozenset({"integer", "real", "complex", "logical", "character"}) @@ -780,7 +786,7 @@ def _convert_data_member( and not getattr(var, "pointer", False) and ( var.base_type.casefold() in {"integer", "real", "complex", "logical"} - or (semantic_type.name == "String" and self._character_length(var).isdigit()) + or (is_string_semantic_type_name(semantic_type.name) and self._character_length(var).isdigit()) ) ): semantic_type.metadata["native_storage"] = True @@ -1156,7 +1162,7 @@ def _normalize_callback_reference_storage( return semantic_type = callback_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): + if written_back or (is_string_semantic_type_name(semantic_type.name) and semantic_type.rank == 0): semantic_type.storage = SemanticStorageContract( kind="array", read_only=False, @@ -2739,6 +2745,11 @@ def _semantic_type_name(self, var: FortranVariable) -> str: semantic_type = self.type_map.get((base_type, kind)) if semantic_type is None: type_text = base_type if kind is None else f"{base_type}(kind={kind})" + if base_type == "character" and kind == "-1": + raise ValueError( + f"Unsupported Fortran semantic type for variable '{var.name}': {type_text}; " + "this compiler does not provide the requested character set (selected_char_kind returned -1)" + ) raise ValueError(f"Unsupported Fortran semantic type for variable '{var.name}': {type_text}") return semantic_type @@ -4225,7 +4236,7 @@ def _is_primitive_scalar_replacement(semantic_type: SemanticType | None) -> bool return bool( semantic_type is not None and semantic_type.rank == 0 - and semantic_type.name != "String" + and not is_string_semantic_type_name(semantic_type.name) and semantic_type.name in SEMANTIC_SCALAR_TYPE_NAMES and not FortranToIRConverter._is_scalar_descriptor(semantic_type) ) @@ -4269,13 +4280,15 @@ def _is_python_value_scalar_output(semantic_type: SemanticType | None) -> bool: semantic_type is not None and semantic_type.rank == 0 and not FortranToIRConverter._is_scalar_descriptor(semantic_type) - and (semantic_type.name == "String" or semantic_type.name in SEMANTIC_SCALAR_TYPE_NAMES) + and semantic_type.name in SEMANTIC_SCALAR_TYPE_NAMES ) @staticmethod def _is_scalar_character(semantic_type: SemanticType | None) -> bool: """Return whether ``semantic_type`` is a rank-zero semantic string.""" - return bool(semantic_type is not None and semantic_type.rank == 0 and semantic_type.name == "String") + return bool( + semantic_type is not None and semantic_type.rank == 0 and is_string_semantic_type_name(semantic_type.name) + ) @staticmethod def _base_classes(dtype: FortranDerivedType) -> list[str]: diff --git a/prik/semantics/pyi2ir.py b/prik/semantics/pyi2ir.py index db935573a..b9b925eca 100644 --- a/prik/semantics/pyi2ir.py +++ b/prik/semantics/pyi2ir.py @@ -26,7 +26,7 @@ is_declaration_expression_helper, is_public_declaration_expression, ) -from prik.semantics.scalar_types import SEMANTIC_SCALAR_TYPE_NAMES +from prik.semantics.scalar_types import SEMANTIC_SCALAR_TYPE_NAMES, is_string_semantic_type_name from prik.semantics.ownership_metadata import ( OWNERSHIP_POLICY_METADATA, set_ownership_metadata, @@ -1845,7 +1845,7 @@ def _native_typed_value_type(self, node: ast.AST) -> str | None: return None if name == "String": raise ValueError('native_call string literals require String[length](value), for example String[1]("N")') - if name in SEMANTIC_SCALAR_TYPE_NAMES and name not in {"String", "Void"}: + if name in SEMANTIC_SCALAR_TYPE_NAMES and name != "Void" and not is_string_semantic_type_name(name): return name return None @@ -2056,7 +2056,7 @@ def semantic_type(self, node: ast.expr) -> SemanticType: if isinstance(node, ast.Call): raise ValueError(f"Unsupported semantic type call: {ast.unparse(node)!r}") - if isinstance(node, ast.Subscript) and self.matches_name(node.value, "String"): + if isinstance(node, ast.Subscript) and self.string_contract_name(node.value) is not None: # One subscription after String is always the character length; an # array adds its shape as a second subscription. return self._character_type(node) @@ -2166,7 +2166,7 @@ def _address_type(self, node: ast.Call) -> SemanticType: def array_type(self, node: ast.Subscript) -> SemanticType: """Load a bracketed scalar type as an array or fixed-length character contract.""" if isinstance(node.value, ast.Subscript): - if self.matches_name(node.value.value, "String"): + if self.string_contract_name(node.value.value) is not None: semantic_type = self._character_type(node.value) return self._array_type_from_dimensions( semantic_type.name, @@ -2384,11 +2384,12 @@ def _character_type(self, node: ast.Subscript) -> SemanticType: allocation, and ``String[...]`` is the assumed length that bare ``String`` also spells. """ + name = self.string_contract_name(node.value) items = self.subscript_items(node) if len(items) != 1: raise ValueError("Character length uses one subscription: String[8], String[n], String[:], or String[...]") if isinstance(items[0], ast.Constant) and items[0].value is Ellipsis: - return SemanticType(name="String", dtype="String", metadata={"fortran_character_length": "*"}) + return SemanticType(name=name, dtype=name, metadata={"fortran_character_length": "*"}) if isinstance(items[0], ast.Slice) and not self._is_deferred_length_slice(node, items[0]): raw_items = self._source_dimension_items(node) spelling = raw_items[0].strip() if raw_items and len(raw_items) == 1 else self.dimension_text(items[0]) @@ -2398,8 +2399,8 @@ def _character_type(self, node: ast.Subscript) -> SemanticType: ) length = self.dimension_text(items[0]) return SemanticType( - name="String", - dtype="String", + name=name, + dtype=name, metadata={"fortran_character_length": length}, ) @@ -2604,7 +2605,7 @@ def _validate_array_copy_metadata(semantic_type: SemanticType) -> None: raise ValueError("COPY_F requires a C-order Python array and targets Fortran order") if array.category in {"assumed_size", "assumed_rank", "runtime_rank"} or array.contiguous is not True: raise ValueError("COPY_F initially supports only dense concrete-shape arrays") - if semantic_type.name == "String" or native_array_descriptor_kind(semantic_type) is not None: + if is_string_semantic_type_name(semantic_type.name) or native_array_descriptor_kind(semantic_type) is not None: raise ValueError("COPY_F does not apply to character arrays or native descriptor handles") @staticmethod @@ -2810,7 +2811,7 @@ def _prototype_transport_spec(self, node: ast.expr) -> _PrototypeArgumentSpec: "bare primitive types are passed by value" ) if ( - semantic_type.name == "String" + is_string_semantic_type_name(semantic_type.name) or semantic_type.storage is not None or self._has_callback_descriptor_metadata(semantic_type) ): @@ -2845,7 +2846,8 @@ def _is_primitive_scalar_value_type(semantic_type: SemanticType) -> bool: """Report whether a callback type is a plain native scalar passed by value.""" return bool( semantic_type.rank == 0 - and semantic_type.name not in {"String", "Void"} + and semantic_type.name != "Void" + and not is_string_semantic_type_name(semantic_type.name) and (semantic_type.dtype or semantic_type.name) in SEMANTIC_SCALAR_TYPE_NAMES and semantic_type.storage is None and not _PyiAstParser._has_callback_descriptor_metadata(semantic_type) @@ -2868,7 +2870,7 @@ def _has_callback_descriptor_metadata(semantic_type: SemanticType) -> bool: def _mark_callback_reference_type(semantic_type: SemanticType) -> None: """Mutate a callback argument type into writable reference-compatible storage.""" storage = semantic_type.storage - if semantic_type.name == "String" and semantic_type.rank == 0: + if is_string_semantic_type_name(semantic_type.name) and semantic_type.rank == 0: semantic_type.storage = SemanticStorageContract( kind="array", read_only=False, @@ -3070,6 +3072,11 @@ def contract_name(self, node: ast.AST) -> str | None: return None return self._contract_bindings.get(node.id) + def string_contract_name(self, node: ast.AST) -> str | None: + """Return ``String`` or ``UString`` when ``node`` names a character contract.""" + name = self.contract_name(node) + return name if is_string_semantic_type_name(name) else None + def matches_name(self, node: ast.AST, name: str) -> bool: """Report whether an AST name resolves to a particular imported contract symbol.""" return self.contract_name(node) == name @@ -3359,7 +3366,7 @@ def _apply_argument_value_projections( semantic_type.name == "String" and str(semantic_type.metadata.get("fortran_character_length", "")) == "1" ) - if semantic_type.rank != 0 or (semantic_type.name == "String" and not is_c_char_value): + if semantic_type.rank != 0 or (is_string_semantic_type_name(semantic_type.name) and not is_c_char_value): raise ValueError( "Value(Arg(i)) is only valid for primitive scalars, String[1], " "or exact rank-zero wrapped derived objects" diff --git a/prik/semantics/scalar_types.py b/prik/semantics/scalar_types.py index b3830218b..779eee942 100644 --- a/prik/semantics/scalar_types.py +++ b/prik/semantics/scalar_types.py @@ -66,6 +66,7 @@ class SemanticScalarSpec: "Int64": SemanticScalarSpec(SemanticScalarFamily.SIGNED_INTEGER, 64), "SizeT": SemanticScalarSpec(SemanticScalarFamily.UNSIGNED_INTEGER), "String": SemanticScalarSpec(SemanticScalarFamily.CHARACTER), + "UString": SemanticScalarSpec(SemanticScalarFamily.CHARACTER), "UInt": SemanticScalarSpec(SemanticScalarFamily.UNSIGNED_INTEGER), "UInt8": SemanticScalarSpec(SemanticScalarFamily.UNSIGNED_INTEGER, 8), "UInt16": SemanticScalarSpec(SemanticScalarFamily.UNSIGNED_INTEGER, 16), @@ -89,6 +90,25 @@ class SemanticScalarSpec: ) +# Character semantic types and the native bytes one character occupies: +# ``String`` for the default kind and ``UString`` for UCS-4 (``ISO_10646``). +CHARACTER_WIDTHS: Final[Mapping[str, int]] = MappingProxyType({"String": 1, "UString": 4}) +STRING_SEMANTIC_TYPE_NAMES: Final[frozenset[str]] = frozenset(CHARACTER_WIDTHS) + + +def is_string_semantic_type_name(name: str | None) -> bool: + """Return whether ``name`` identifies a character string of either width.""" + return name in STRING_SEMANTIC_TYPE_NAMES + + +def character_width(name: str) -> int: + """Return the native bytes one character of a string semantic type occupies. + + Non-string names raise ``KeyError`` so callers cannot invent a width. + """ + return CHARACTER_WIDTHS[name] + + def is_boolean_semantic_type_name(name: str | None) -> bool: """Return whether ``name`` identifies a supported Boolean storage contract.""" return name in BOOLEAN_SEMANTIC_TYPE_NAMES @@ -111,12 +131,16 @@ def boolean_storage_bits(name: str) -> int: __all__ = ( "BOOLEAN_SEMANTIC_TYPE_NAMES", "BOOLEAN_STORAGE_BITS", + "CHARACTER_WIDTHS", "SEMANTIC_SCALAR_TYPES", "SEMANTIC_SCALAR_TYPE_NAMES", + "STRING_SEMANTIC_TYPE_NAMES", "SemanticScalarFamily", "SemanticScalarSpec", "boolean_storage_bits", + "character_width", "is_boolean_semantic_type_name", + "is_string_semantic_type_name", ) diff --git a/tests/fortran/_support/wrapper_build.py b/tests/fortran/_support/wrapper_build.py index f8dd61425..f41517db7 100644 --- a/tests/fortran/_support/wrapper_build.py +++ b/tests/fortran/_support/wrapper_build.py @@ -202,6 +202,28 @@ def _require_maybe_unallocated_function_result_support() -> None: pytest.skip("gfortran rejects allocatable function results as allocatable helper arguments") +@cache +def _supports_ucs4_characters() -> bool: + """Check that the test compiler provides the ``ISO_10646`` character kind.""" + source = "module probe\n character(kind=selected_char_kind('ISO_10646'), len=1) :: value\nend module probe\n" + with TemporaryDirectory() as directory: + path = Path(directory) / "probe.f90" + path.write_text(source, encoding="utf-8") + result = subprocess.run( + [_compiler(), "-c", str(path), "-o", str(Path(directory) / "probe.o")], + capture_output=True, + text=True, + check=False, + cwd=directory, + ) + return result.returncode == 0 + + +def _require_ucs4_character_support() -> None: + if not _supports_ucs4_characters(): + pytest.skip("the Fortran compiler does not provide the ISO_10646 character kind") + + def _compile_native_object(source: Path, native_dir: Path) -> Path: native_dir.mkdir(parents=True, exist_ok=True) native_source = native_dir / source.name diff --git a/tests/fortran/data_types/probes/test_fortran_type_probes.py b/tests/fortran/data_types/probes/test_fortran_type_probes.py index 0d129da6f..6a378d4cb 100644 --- a/tests/fortran/data_types/probes/test_fortran_type_probes.py +++ b/tests/fortran/data_types/probes/test_fortran_type_probes.py @@ -25,6 +25,7 @@ fortran_type_probe_cache_key, fortran_type_probe_expressions, load_fortran_type_probe_report, + probe_can_resolve_expression, probe_fortran_type_expressions, probe_fortran_type_expressions_cached, resolve_fortran_logical_storage_types, @@ -90,6 +91,16 @@ def test_fortran_type_probe_rejects_statement_injection(): build_fortran_type_probe_source(["selected_real_kind(12); stop"]) +def test_fortran_type_probe_accepts_only_quoted_character_set_names(): + """A kind selector may name a character set; any other quoted text stays refused.""" + expression = "selected_char_kind('ISO_10646')" + assert expression in build_fortran_type_probe_source([expression]) + assert probe_can_resolve_expression(expression) + for unsafe in ("selected_char_kind('ISO 10646')", 'selected_char_kind("ISO_10646")'): + with pytest.raises(FortranTypeProbeError, match="unsupported characters"): + build_fortran_type_probe_source([unsafe]) + + def test_fortran_type_probe_requires_an_explicit_compiler(): with pytest.raises(FortranTypeProbeError, match="exact compiler"): probe_fortran_type_expressions( diff --git a/tests/fortran/strings/end_to_end/fixtures/native/fucs4_strings.f90 b/tests/fortran/strings/end_to_end/fixtures/native/fucs4_strings.f90 new file mode 100644 index 000000000..0b02512f8 --- /dev/null +++ b/tests/fortran/strings/end_to_end/fixtures/native/fucs4_strings.f90 @@ -0,0 +1,77 @@ +module ucs4_strings + implicit none + integer, parameter :: ucs4 = selected_char_kind('ISO_10646') + + character(kind=ucs4, len=4) :: label = ucs4_'abcd' + character(kind=ucs4, len=:), allocatable :: note + character(kind=ucs4, len=2) :: grid(3) = [character(kind=ucs4, len=2) :: ucs4_'aa', ucs4_'bb', ucs4_'cc'] + character(kind=ucs4, len=3), allocatable :: words(:) + + type :: record + character(kind=ucs4, len=3) :: code = ucs4_'xyz' + end type record + + abstract interface + subroutine visitor(text) + import :: ucs4 + character(kind=ucs4, len=3), intent(in) :: text + end subroutine visitor + end interface +contains + function count_code(text, code) result(n) + character(kind=ucs4, len=*), intent(in) :: text + integer, intent(in) :: code + integer :: n, i + n = 0 + do i = 1, len(text) + if (ichar(text(i:i)) == code) n = n + 1 + end do + end function count_code + + function tag() result(text) + character(kind=ucs4, len=3) :: text + text = char(960, ucs4) // ucs4_'ab' + end function tag + + subroutine reverse(text) + character(kind=ucs4, len=4), intent(inout) :: text + text = text(4:4) // text(3:3) // text(2:2) // text(1:1) + end subroutine reverse + + function describe(n) result(text) + integer, intent(in) :: n + character(kind=ucs4, len=:), allocatable :: text + text = repeat(char(960, ucs4), n) + end function describe + + subroutine grow(text) + character(kind=ucs4, len=:), allocatable, intent(inout) :: text + text = text // char(20013, ucs4) + end subroutine grow + + subroutine shift(items) + character(kind=ucs4, len=2), intent(inout) :: items(:) + items = items(size(items):1:-1) + end subroutine shift + + function pair() result(items) + character(kind=ucs4, len=2) :: items(2) + items = [character(kind=ucs4, len=2) :: char(960, ucs4) // ucs4_'1', ucs4_'z2'] + end function pair + + subroutine each(f) + procedure(visitor) :: f + call f(ucs4_'ab' // char(960, ucs4)) + end subroutine each + + subroutine fill_words() + allocate(words(2)) + words = [character(kind=ucs4, len=3) :: ucs4_'abc', char(960, ucs4) // ucs4_'xy'] + end subroutine fill_words + + function label_code(i) result(code) + integer, intent(in) :: i + integer :: code + code = ichar(label(i:i)) + end function label_code +end module ucs4_strings diff --git a/tests/fortran/strings/end_to_end/test_ucs4_strings.py b/tests/fortran/strings/end_to_end/test_ucs4_strings.py new file mode 100644 index 000000000..ab6c57a86 --- /dev/null +++ b/tests/fortran/strings/end_to_end/test_ucs4_strings.py @@ -0,0 +1,73 @@ +"""UCS-4 character values cross every string boundary the default kind does.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from tests.fortran._support.wrapper_build import ( + _build_generated_pyi_and_import, + _build_source_and_import, + _require_ucs4_character_support, +) + +SOURCE = Path(__file__).parent / "fixtures" / "native" / "fucs4_strings.f90" +GENERATED = { + "bind_c_fucs4_strings_wrapper.f90", + "fucs4_strings_wrapper.c", + "fucs4_strings_wrapper.h", +} +pytestmark = pytest.mark.fortran_end_to_end + + +def _assert_ucs4_strings(module) -> None: + """Check scalar, storage, array, handle, and callback UCS-4 boundaries.""" + # Scalar str arguments and results carry code points beyond one byte. + assert module.count_code("π中πx", np.int32(960)) == 2 + assert module.tag() == "πab" + assert module.reverse("ab中d") == "d中ba" + assert module.describe(np.int32(3)) == "πππ" + assert module.grow("中") == "中中" + with pytest.raises(TypeError, match="exactly 4 characters"): + module.reverse("abc") + + # Stored scalars are live U views; assignment and view writes reach native code. + label = module.label + assert label.dtype == np.dtype("U4") and label[()] == "abcd" + label[...] = "π中xy" + assert module.label_code(np.int32(1)) == 960 + module.label = "wxyz" + assert module.label_code(np.int32(2)) == ord("x") + assert module.note is None + module.note = "héllo" + assert module.note[()] == "héllo" + record = module.Record() + assert record.code.dtype == np.dtype("U3") and record.code[()] == "xyz" + + # Arrays, array results, module arrays, and descriptor handles use U. + items = np.array(["ab", "中π"], dtype="U2") + module.shift(items) + assert items.tolist() == ["中π", "ab"] + assert module.pair().tolist() == ["π1", "z2"] + assert module.grid.tolist() == ["aa", "bb", "cc"] + module.fill_words() + assert module.words.to_numpy().tolist() == ["abc", "πxy"] + + # A callback receives the characters as a Python str. + seen = [] + module.each(lambda text: seen.append(str(text))) + assert seen == ["abπ"] + + +def test_ucs4_strings_match_in_source_and_contract_builds(tmp_path: Path): + _require_ucs4_character_support() + source_module = _build_source_and_import(SOURCE, tmp_path / "source", GENERATED) + contract_module = _build_generated_pyi_and_import(SOURCE, tmp_path / "contract") + for module in (source_module, contract_module): + _assert_ucs4_strings(module) + + contract = (tmp_path / "contract" / "contracts" / SOURCE.stem / "ucs4_strings.pyi").read_text(encoding="utf-8") + for spelling in ("label: UString[4][()]", "note: Allocatable[UString[:]]", "def tag() -> UString[3]"): + assert spelling in contract diff --git a/tests/fortran/strings/semantics/test_fortran_string_semantics.py b/tests/fortran/strings/semantics/test_fortran_string_semantics.py index d701412b6..043ac56a5 100644 --- a/tests/fortran/strings/semantics/test_fortran_string_semantics.py +++ b/tests/fortran/strings/semantics/test_fortran_string_semantics.py @@ -1,5 +1,7 @@ """Tests split by stable ownership concept from `test_compile_time_values.py`.""" +import pytest + from prik.semantics.fortran2ir import 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 @@ -78,3 +80,32 @@ def test_every_character_declaration_form_states_its_own_length(): 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"} + + +@pytest.mark.parametrize( + ("kind", "expected"), + [ + pytest.param("selected_char_kind('ISO_10646')", "UString", id="iso-10646"), + pytest.param("4", "UString", id="kind-4"), + pytest.param("selected_char_kind('ASCII')", "String", id="ascii"), + pytest.param("selected_char_kind('DEFAULT')", "String", id="default"), + pytest.param("c_char", "String", id="c-char"), + ], +) +def test_character_kind_selects_the_string_width(kind: str, expected: str): + """A character kind names UCS-4 or the one-byte kind without a compiler probe.""" + parsed = parse_fortran_source( + f""" +module kinds + use iso_c_binding, only: c_char +contains + subroutine take(text) + character(kind={kind}, len=4), intent(in) :: text + end subroutine take +end module kinds +""" + ) + + func = get_function(fortran_module_to_semantic_module(parsed), "take") + + assert func.arguments[0].semantic_type.name == expected diff --git a/tests/fortran/strings/semantics/test_string_pyi_semantics.py b/tests/fortran/strings/semantics/test_string_pyi_semantics.py index 96a4934ca..e08e12e80 100644 --- a/tests/fortran/strings/semantics/test_string_pyi_semantics.py +++ b/tests/fortran/strings/semantics/test_string_pyi_semantics.py @@ -5,17 +5,19 @@ from tests.fortran._support.pyi_conversion import parse_pyi_text -def test_string_length_and_shape_axes_round_trip(): +@pytest.mark.parametrize("name", ["String", "UString"]) +def test_string_length_and_shape_axes_round_trip(name: str): + """Both character widths spell every length and shape form the same way.""" module = parse_pyi_text( - """ -def scalar_unknown(value: String) -> None: ... -def scalar_fixed(value: String[8]) -> None: ... -def array_unknown(values: String[:][:]) -> None: ... -def array_fixed(values: String[8][:]) -> None: ... -def scalar_storage(value: String[8][()]) -> None: ... -def scalar_deferred(value: String[:]) -> None: ... -def array_assumed(values: String[...][:]) -> None: ... -def array_assumed_strided(values: String[...][::]) -> None: ... + f""" +def scalar_unknown(value: {name}) -> None: ... +def scalar_fixed(value: {name}[8]) -> None: ... +def array_unknown(values: {name}[:][:]) -> None: ... +def array_fixed(values: {name}[8][:]) -> None: ... +def scalar_storage(value: {name}[8][()]) -> None: ... +def scalar_deferred(value: {name}[:]) -> None: ... +def array_assumed(values: {name}[...][:]) -> None: ... +def array_assumed_strided(values: {name}[...][::]) -> None: ... """, module_name="string_axes", ) @@ -31,6 +33,7 @@ def array_assumed_strided(values: String[...][::]) -> None: ... array_assumed_strided, ) = module.functions + assert {function.arguments[0].semantic_type.name for function in module.functions} == {name} assert "fortran_character_length" not in scalar_unknown.arguments[0].semantic_type.metadata assert scalar_fixed.arguments[0].semantic_type.metadata["fortran_character_length"] == "8" @@ -60,14 +63,14 @@ def array_assumed_strided(values: String[...][::]) -> None: ... assert array_assumed_strided.arguments[0].semantic_type.shape == ["::"] emitted = emit_module(module) - assert "value: String" in emitted - assert "value: String[8]" in emitted - assert "values: String[:][:]" in emitted - assert "values: String[8][:]" in emitted - assert "value: String[8][()]" in emitted - assert "value: String[:]" in emitted - assert "values: String[...][:]" in emitted - assert "values: String[...][::]" in emitted + assert f"value: {name}" in emitted + assert f"value: {name}[8]" in emitted + assert f"values: {name}[:][:]" in emitted + assert f"values: {name}[8][:]" in emitted + assert f"value: {name}[8][()]" in emitted + assert f"value: {name}[:]" in emitted + assert f"values: {name}[...][:]" in emitted + assert f"values: {name}[...][::]" in emitted assert parse_pyi_text(emitted, module_name="string_axes") == module From 8cfb22a5b877b9e8630f2afc749da5f0261eef39 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 27 Sep 2026 16:24:58 +0100 Subject: [PATCH 04/11] codex: note that a UString str argument is converted into call storage Co-Authored-By: Claude Opus 5.5 --- docs/user/guide/strings.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/user/guide/strings.md b/docs/user/guide/strings.md index 9e312d524..17c3d9c9f 100644 --- a/docs/user/guide/strings.md +++ b/docs/user/guide/strings.md @@ -273,10 +273,11 @@ title: UString[8][()] ``` A `UString[8]` length counts characters, not bytes, so it accepts any `str` of -exactly eight characters. Declare the kind with -`selected_char_kind('ISO_10646')`, and use a compiler that provides it: GNU -Fortran and LLVM Flang do, Intel `ifx` does not. A kind that selects -`'ASCII'` or `'DEFAULT'` is an ordinary `String`. +exactly eight characters. A `str` argument is converted into four-byte call +storage; pass NumPy `U` storage to share memory with Fortran instead. +Declare the kind with `selected_char_kind('ISO_10646')`, and use a compiler +that provides it: GNU Fortran and LLVM Flang do, Intel `ifx` does not. A kind +that selects `'ASCII'` or `'DEFAULT'` is an ordinary `String`. ## Allocatable And Pointer Scalar Strings From cc38d4cf7ecfee65c7dd3a0fff0dc1b12dbcfadd Mon Sep 17 00:00:00 2001 From: said Date: Sun, 27 Sep 2026 16:39:50 +0100 Subject: [PATCH 05/11] codex: classify character kinds by the set they select, not a kind number Kind numbers are processor-dependent, so character(kind=4) no longer means UCS-4; only selected_char_kind('ISO_10646'), directly or through a parameter, selects UString. A known selector keeps its spelling instead of being replaced by its probed number, which now only reports a compiler lacking the set. Function results resolve their character selector against module parameters the way arguments already did. Co-Authored-By: Claude Opus 5.5 --- prik/parsers/fortran/parser.py | 14 ++++++++------ prik/semantics/fortran2ir.py | 11 ++++++++--- .../semantics/test_fortran_string_semantics.py | 18 +++++++++++++----- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/prik/parsers/fortran/parser.py b/prik/parsers/fortran/parser.py index cf00dfb59..27fa81fa5 100644 --- a/prik/parsers/fortran/parser.py +++ b/prik/parsers/fortran/parser.py @@ -5675,12 +5675,14 @@ def _resolve_procedure_signature_facts( 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: - signature.result.kind = FortranParser._resolve_kind_expression( - signature.result.kind, - visible_symbols, - resolver=resolver, - ) + if signature.result: + if signature.result.kind: + signature.result.kind = FortranParser._resolve_kind_expression( + signature.result.kind, + visible_symbols, + resolver=resolver, + ) + FortranParser._resolve_character_length(signature.result, visible_symbols, resolver=resolver) @staticmethod def _resolve_procedure_compile_time_facts( diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index cc4add1de..8a2a5d9ba 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -170,8 +170,6 @@ ("character", "selected_char_kind('default')"): "String", ("character", "selected_char_kind('ascii')"): "String", ("character", "selected_char_kind('iso_10646')"): "UString", - # Every compiler with a four-byte character kind numbers UCS-4 as kind 4. - ("character", "4"): "UString", } _FORTRAN_INTRINSIC_TYPES = frozenset({"integer", "real", "complex", "logical", "character"}) @@ -2799,7 +2797,14 @@ def _character_kind_expression(self, var: FortranVariable) -> str | None: declared = getattr(var, "character_kind_expression", None) if not declared: return None - return self._resolve_compile_time_text(str(declared)).strip().lower() or None + resolved = self._resolve_compile_time_text(str(declared)).strip().lower() or None + # The character set a kind selects is its identity; the number a + # compiler assigns to it is not portable, so a known selector is kept + # as written. Its probed value still reports a set the compiler lacks. + spelled = str(declared).strip().lower() + if ("character", spelled) in self.type_map and resolved != "-1": + return spelled + return resolved 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/strings/semantics/test_fortran_string_semantics.py b/tests/fortran/strings/semantics/test_fortran_string_semantics.py index 043ac56a5..6692d9055 100644 --- a/tests/fortran/strings/semantics/test_fortran_string_semantics.py +++ b/tests/fortran/strings/semantics/test_fortran_string_semantics.py @@ -86,26 +86,34 @@ def test_every_character_declaration_form_states_its_own_length(): ("kind", "expected"), [ pytest.param("selected_char_kind('ISO_10646')", "UString", id="iso-10646"), - pytest.param("4", "UString", id="kind-4"), + pytest.param("ucs4", "UString", id="iso-10646-parameter"), pytest.param("selected_char_kind('ASCII')", "String", id="ascii"), pytest.param("selected_char_kind('DEFAULT')", "String", id="default"), pytest.param("c_char", "String", id="c-char"), ], ) def test_character_kind_selects_the_string_width(kind: str, expected: str): - """A character kind names UCS-4 or the one-byte kind without a compiler probe.""" + """A character kind names UCS-4 or the one-byte kind without a compiler probe. + + The kind is read from the character set it selects, never from a kind + number, which each compiler assigns for itself. + """ parsed = parse_fortran_source( f""" module kinds use iso_c_binding, only: c_char + integer, parameter :: ucs4 = selected_char_kind('ISO_10646') contains - subroutine take(text) + function copy(text) result(out) character(kind={kind}, len=4), intent(in) :: text - end subroutine take + character(kind={kind}, len=4) :: out + out = text + end function copy end module kinds """ ) - func = get_function(fortran_module_to_semantic_module(parsed), "take") + func = get_function(fortran_module_to_semantic_module(parsed), "copy") assert func.arguments[0].semantic_type.name == expected + assert func.return_type.name == expected From ece7cba7fb1105f8ea035bae163aaaf0f7b93788 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 27 Sep 2026 16:42:04 +0100 Subject: [PATCH 06/11] codex: run Open MPI integration on pushes to main and release branches Co-Authored-By: Claude Opus 5.5 --- .github/workflows/openmpi-integration.yml | 4 ++++ CHANGELOG.md | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/openmpi-integration.yml b/.github/workflows/openmpi-integration.yml index 706d4d620..a385b2fda 100644 --- a/.github/workflows/openmpi-integration.yml +++ b/.github/workflows/openmpi-integration.yml @@ -2,6 +2,10 @@ name: Open MPI Integration on: workflow_call: + push: + branches: + - main + - release/* workflow_dispatch: permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e561d7fa..b3ec4f55d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,8 @@ release tags add a leading `v` to the package version. the Open MPI tutorial's extension drops from 245 to 164 ns. - Open MPI integration CI now runs the `mpi_f08` tutorial on Linux and macOS against Open MPI 4.1 and 5.0 with paired GNU C/Fortran compilers, and - compares its two-rank result with mpi4py built from the same installation. + compares its two-rank result with mpi4py built from the same installation, + on every pull request and on pushes to `main` and release branches. The tutorial provides a repeatable matched-installation benchmark and a labeled local results table comparing its wrapped API and mpi4py-style Python API with mpi4py, including relative timings. The benchmark binds From e5b69311b1b23168f24326ae8e5a2be689d1c90c Mon Sep 17 00:00:00 2001 From: said Date: Sun, 27 Sep 2026 17:00:48 +0100 Subject: [PATCH 07/11] codex: classify numeric character kinds from the compiler's character sets A numeric character kind such as kind=4 names whichever character set the target compiler numbers that way. Semantic requirement collection now asks the probe for selected_char_kind('DEFAULT'), 'ASCII', and 'ISO_10646', and the converter builds one compiler-specific map from kind number to String or UString. A number the compiler assigns to none of them is refused with a message saying so. Selector spellings are still classified by the set they name, without a probe. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- docs/user/guide/strings.md | 8 ++-- prik/semantics/fortran2ir.py | 40 +++++++++++++++++- .../semantics/test_compile_time_values.py | 2 +- .../parsing/test_character_length_parsing.py | 6 ++- .../test_fortran_string_semantics.py | 42 ++++++++++++++++++- 6 files changed, 94 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3ec4f55d..82f741f62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,9 @@ release tags add a leading `v` to the package version. supported through the new `UString` contract type, which takes every form `String` does and maps storage to NumPy `U` instead of `S`. A character kind given by `selected_char_kind('ASCII')` or `'DEFAULT'` is now - an ordinary `String` instead of an unsupported kind. + an ordinary `String` instead of an unsupported kind. A numeric character + kind such as `kind=4` is classified by asking the compiler which character + set it numbers that way. - **Breaking:** scalar fields of a Fortran derived type read as live rank-zero NumPy views of the object's storage, as module variables do. A numeric or logical field returns a writable `T[()]` view instead of a NumPy scalar, and diff --git a/docs/user/guide/strings.md b/docs/user/guide/strings.md index 17c3d9c9f..46e8a297c 100644 --- a/docs/user/guide/strings.md +++ b/docs/user/guide/strings.md @@ -275,9 +275,11 @@ title: UString[8][()] A `UString[8]` length counts characters, not bytes, so it accepts any `str` of exactly eight characters. A `str` argument is converted into four-byte call storage; pass NumPy `U` storage to share memory with Fortran instead. -Declare the kind with `selected_char_kind('ISO_10646')`, and use a compiler -that provides it: GNU Fortran and LLVM Flang do, Intel `ifx` does not. A kind -that selects `'ASCII'` or `'DEFAULT'` is an ordinary `String`. +Declare the kind with `selected_char_kind('ISO_10646')`, or with a kind number +the compiler assigns to that set, such as `kind=4` on GNU Fortran; PRIK asks +the compiler which set a number names. The compiler must provide the set: GNU +Fortran and LLVM Flang do, Intel `ifx` does not. A kind that selects +`'ASCII'` or `'DEFAULT'` is an ordinary `String`. ## Allocatable And Pointer Scalar Strings diff --git a/prik/semantics/fortran2ir.py b/prik/semantics/fortran2ir.py index 8a2a5d9ba..b234e369f 100644 --- a/prik/semantics/fortran2ir.py +++ b/prik/semantics/fortran2ir.py @@ -16,7 +16,7 @@ from __future__ import annotations from collections.abc import Iterable, Iterator, Mapping -from typing import NamedTuple +from typing import Final, NamedTuple from copy import deepcopy from dataclasses import dataclass, replace import re @@ -172,6 +172,15 @@ ("character", "selected_char_kind('iso_10646')"): "UString", } +# The character sets a compiler numbers for itself, and the string type each is. +# Probing these selectors maps a numeric character kind to its set on the +# target compiler, since kind numbers are not portable. +CHARACTER_SET_SELECTORS: Final[tuple[tuple[str, str], ...]] = ( + ("selected_char_kind('DEFAULT')", "String"), + ("selected_char_kind('ASCII')", "String"), + ("selected_char_kind('ISO_10646')", "UString"), +) + _FORTRAN_INTRINSIC_TYPES = frozenset({"integer", "real", "complex", "logical", "character"}) _FORTRAN_STORAGE_PROBE_TYPES = frozenset({"integer", "real", "complex", "logical"}) _FORTRAN_STORAGE_TYPE_MAP = { @@ -275,6 +284,20 @@ class _DeclarationCallableContext: uses: list[FortranUseStatement] +def _character_kinds(compile_time_values: Mapping[str, str]) -> dict[str, str]: + """Map each character kind number the target compiler assigns to its string type. + + The numbers come from probing ``CHARACTER_SET_SELECTORS``; a set the + compiler does not provide reports ``-1`` and adds nothing. + """ + kinds: dict[str, str] = {} + for selector, semantic_type in CHARACTER_SET_SELECTORS: + number = compile_time_values.get(selector.lower()) + if number is not None and number.lstrip("-").isdigit() and int(number) > 0: + kinds[number] = semantic_type + return kinds + + def _normalize_compile_time_values( compile_time_values: dict[str, int | str] | None, ) -> dict[str, str]: @@ -367,6 +390,7 @@ def __init__( self._abstract_derived_types: set[tuple[str, str]] = set() self.type_map = FORTRAN_TYPE_MAP if type_map is None else type_map self.compile_time_values = _normalize_compile_time_values(compile_time_values) + self.character_kinds = _character_kinds(self.compile_time_values) self.wrapped_derived_types = { (str(module).lower(), str(name).lower()) for module, name in (wrapped_derived_types or []) } @@ -2741,6 +2765,8 @@ def _semantic_type_name(self, var: FortranVariable) -> str: kind = self._semantic_kind_key(var) semantic_type = self.type_map.get((base_type, kind)) + if semantic_type is None and base_type == "character": + semantic_type = self.character_kinds.get(kind) if semantic_type is None: type_text = base_type if kind is None else f"{base_type}(kind={kind})" if base_type == "character" and kind == "-1": @@ -2748,6 +2774,11 @@ def _semantic_type_name(self, var: FortranVariable) -> str: f"Unsupported Fortran semantic type for variable '{var.name}': {type_text}; " "this compiler does not provide the requested character set (selected_char_kind returned -1)" ) + if base_type == "character" and kind is not None and kind.isdigit() and self.character_kinds: + raise ValueError( + f"Unsupported Fortran semantic type for variable '{var.name}': {type_text}; " + f"this compiler numbers no default, ASCII, or ISO_10646 character set as kind {kind}" + ) raise ValueError(f"Unsupported Fortran semantic type for variable '{var.name}': {type_text}") return semantic_type @@ -4541,6 +4572,8 @@ def _compile_time_requirement_message(code: str, symbol: str, expression: str) - return f"Parameter '{symbol}' needs a compile-time value for expression '{expression}'." if code == "unsupported_kind": return f"Kind expression for '{symbol}' needs a supported compile-time value." + if code == "character_set": + return f"Character set '{expression}' needs the kind number the compiler assigns it." return f"Compile-time value required for '{symbol}'." @@ -4724,6 +4757,11 @@ def add_requirement( base_type=base_type, kind=kind_key, ) + if base_type == "character": + # A numeric character kind names whichever set the target + # compiler numbers that way, so the sets' numbers are measured. + for selector, _semantic_type in CHARACTER_SET_SELECTORS: + add_requirement("character_set", {}, expression=selector, base_type=base_type) return requirements 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 be8f94620..b40a1331d 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 @@ -370,7 +370,7 @@ def test_semantic_compile_time_requirements_cover_all_parser_contexts(): ] ) ) - assert {item["symbol"] for item in unsupported} == { + assert {item["symbol"] for item in unsupported if item["code"] == "unsupported_kind"} == { "bad_integer", "bad_real", "bad_complex", diff --git a/tests/fortran/strings/parsing/test_character_length_parsing.py b/tests/fortran/strings/parsing/test_character_length_parsing.py index a9d011bcc..dea4515b9 100644 --- a/tests/fortran/strings/parsing/test_character_length_parsing.py +++ b/tests/fortran/strings/parsing/test_character_length_parsing.py @@ -92,4 +92,8 @@ def test_a_recorded_selector_is_what_semantics_reads_for_a_character_kind(): 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")] + assert [ + (item["symbol"], item["kind"], item["expression"]) + for item in requirements + if item["code"] == "unsupported_kind" + ] == [("x", "bad", "bad")] diff --git a/tests/fortran/strings/semantics/test_fortran_string_semantics.py b/tests/fortran/strings/semantics/test_fortran_string_semantics.py index 6692d9055..f442611d3 100644 --- a/tests/fortran/strings/semantics/test_fortran_string_semantics.py +++ b/tests/fortran/strings/semantics/test_fortran_string_semantics.py @@ -2,7 +2,7 @@ import pytest -from prik.semantics.fortran2ir import fortran_module_to_semantic_module +from prik.semantics.fortran2ir import collect_semantic_compile_time_requirements, 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 @@ -117,3 +117,43 @@ def test_character_kind_selects_the_string_width(kind: str, expected: str): assert func.arguments[0].semantic_type.name == expected assert func.return_type.name == expected + + +NUMERIC_KIND_SOURCE = """ +module kinds +contains + subroutine take(text) + character(kind=4, len=4), intent(in) :: text + end subroutine take +end module kinds +""" + + +@pytest.mark.parametrize( + ("ucs4_number", "expected"), + [ + pytest.param(4, "UString", id="compiler-numbers-ucs4-as-4"), + pytest.param(-1, None, id="compiler-without-ucs4"), + ], +) +def test_numeric_character_kind_reads_the_compiler_character_sets(ucs4_number: int, expected: str | None): + """A numeric kind is whichever character set the target compiler numbers that way.""" + parsed = parse_fortran_source(NUMERIC_KIND_SOURCE) + requirements = collect_semantic_compile_time_requirements(parsed) + assert {item["expression"] for item in requirements if item["code"] == "character_set"} == { + "selected_char_kind('DEFAULT')", + "selected_char_kind('ASCII')", + "selected_char_kind('ISO_10646')", + } + probed = { + "selected_char_kind('DEFAULT')": 1, + "selected_char_kind('ASCII')": 1, + "selected_char_kind('ISO_10646')": ucs4_number, + } + + if expected is None: + with pytest.raises(ValueError, match="numbers no default, ASCII, or ISO_10646 character set as kind 4"): + fortran_module_to_semantic_module(parsed, compile_time_values=probed) + return + func = get_function(fortran_module_to_semantic_module(parsed, compile_time_values=probed), "take") + assert func.arguments[0].semantic_type.name == expected From 0d8e4a4217dea66409c6b21f070d6db72a24e07c Mon Sep 17 00:00:00 2001 From: said Date: Sun, 27 Sep 2026 17:30:08 +0100 Subject: [PATCH 08/11] codex: remove duplicated scalar-field lowering in the bridge, binding, and planner The allocatable- and pointer-holder field lowerings in the bridge and the binding differed only in holder type, capsule, and symbol names, so each backend now lowers holder fields once. Private field methods share one owner-argument parser, stored-field getters share one view builder, string validation is one function for both character widths, and field entrypoints share one operation and character-buffer constructor. Generated sources are byte-identical for every fixture in the test corpus. Co-Authored-By: Claude Opus 5.5 --- prik/codegen/c/binding.py | 393 ++++++++++++--------------------- prik/codegen/fortran/bridge.py | 100 +++------ prik/planning/entrypoints.py | 201 +++++------------ 3 files changed, 234 insertions(+), 460 deletions(-) diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index c8c026444..b7c013a19 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -2692,7 +2692,14 @@ def _allocatable_holder_functions_for_plan(self, plan: ModulePlan) -> tuple[CFun function for derived in derived_types for field in derived.fields - for function in self._allocatable_holder_field_functions(derived, field) + for function in self._holder_field_functions( + derived, + field, + "allocatable", + self._allocatable_holder_owner_nodes, + self._allocatable_holder_field_method_name, + self._allocatable_holder_field_bridge_name, + ) ) presence = tuple(self._allocatable_holder_presence_method(derived) for derived in derived_types) return (*presence, *fields) @@ -2704,7 +2711,14 @@ def _pointer_holder_functions_for_plan(self, plan: ModulePlan) -> tuple[CFunctio function for derived in derived_types for field in derived.fields - for function in self._pointer_holder_field_functions(derived, field) + for function in self._holder_field_functions( + derived, + field, + "pointer", + self._pointer_holder_owner_nodes, + self._pointer_holder_field_method_name, + self._pointer_holder_field_bridge_name, + ) ) presence = tuple(self._pointer_holder_presence_method(derived) for derived in derived_types) return (*presence, *fields) @@ -2741,57 +2755,46 @@ def _allocatable_holder_presence_method(self, derived: DerivedTypePlan) -> CFunc ), ) - def _allocatable_holder_field_functions( + def _holder_field_functions( self, derived: DerivedTypePlan, field: DerivedFieldPlan, + holder: str, + owner_nodes, + method_name, + bridge_name, ) -> tuple[CFunction, ...]: - """Expose scalar holder fields through holder-checked private methods.""" + """Expose one scalar field of an allocatable- or pointer-held object through holder-checked methods. + + The holders differ in how the checked owner is extracted, which + ``owner_nodes`` supplies; the field access itself is the same. + """ scalar = PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name) - owner_nodes = self._allocatable_holder_owner_nodes(derived.backend_symbol, setter=False) + getter_call = f"{bridge_name(derived, field, 'get')}(owner_address)" if field.access is DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW: - getter = self._derived_private_method( - self._allocatable_holder_field_method_name(derived, field, "get"), - ( - *owner_nodes, - *self._scalar_storage_view_nodes( - f"{self._allocatable_holder_field_bridge_name(derived, field, 'get')}(owner_address)", - field.semantic_type_name, - None, - "owner_obj", - ), - ), + value_nodes: tuple = self._scalar_storage_view_nodes( + getter_call, field.semantic_type_name, None, "owner_obj" ) elif field.access is not DerivedFieldAccessMechanism.SCALAR_VALUE: - raise ValueError(f"Unsupported allocatable-holder field for {field.owner_path!r}: {field.access.value}") + raise ValueError(f"Unsupported {holder}-holder field for {field.owner_path!r}: {field.access.value}") else: - getter = self._derived_private_method( - self._allocatable_holder_field_method_name(derived, field, "get"), - ( - *owner_nodes, - CDeclaration( - "value", - scalar.c_spelling, - CodeExpression( - self._allocatable_holder_field_bridge_name(derived, field, "get") + "(owner_address)" - ), - ), - CReturn(CodeExpression(self._scalar_result_expression(scalar, "&value"))), - ), + value_nodes = ( + CDeclaration("value", scalar.c_spelling, CodeExpression(getter_call)), + CReturn(CodeExpression(self._scalar_result_expression(scalar, "&value"))), ) + getter = self._derived_private_method( + method_name(derived, field, "get"), + (*owner_nodes(derived.backend_symbol, setter=False), *value_nodes), + ) if field.setter_action is not SetterAction.WRITE_THROUGH: return (getter,) setter = self._derived_private_method( - self._allocatable_holder_field_method_name(derived, field, "set"), + method_name(derived, field, "set"), ( - *self._allocatable_holder_owner_nodes(derived.backend_symbol, setter=True), + *owner_nodes(derived.backend_symbol, setter=True), CDeclaration("value", scalar.c_spelling), self._scalar_field_unpack_statement(field, scalar, "value_obj", "value"), - CExpressionStatement( - CodeExpression( - f"{self._allocatable_holder_field_bridge_name(derived, field, 'set')}(owner_address, value)" - ) - ), + CExpressionStatement(CodeExpression(f"{bridge_name(derived, field, 'set')}(owner_address, value)")), CExpressionStatement(CodeExpression("Py_RETURN_NONE")), ), ) @@ -2799,15 +2802,13 @@ def _allocatable_holder_field_functions( def _allocatable_holder_owner_nodes(self, type_name: str, *, setter: bool) -> tuple: """Parse property arguments and extract one exact typed-holder capsule.""" - declarations: tuple = (CDeclaration("owner_obj", "PyObject *"),) - if setter: - declarations = (*declarations, CDeclaration("value_obj", "PyObject *")) - parse = 'if (!PyArg_ParseTuple(args, "OO", &owner_obj, &value_obj)) return NULL' - else: - parse = 'if (!PyArg_ParseTuple(args, "O", &owner_obj)) return NULL' + return self._holder_owner_nodes(self._allocatable_holder_capsule_name(type_name), setter=setter) + + @staticmethod + def _holder_owner_nodes(capsule_name: str, *, setter: bool) -> tuple: + """Parse a holder method's arguments and extract the holder's typed capsule address.""" return ( - *declarations, - CExpressionStatement(CodeExpression(parse)), + *CBindingGenerator._owner_argument_nodes(value=setter), CDeclaration( "owner_capsule", "PyObject *", @@ -2817,9 +2818,7 @@ def _allocatable_holder_owner_nodes(self, type_name: str, *, setter: bool) -> tu CDeclaration( "owner_address", "void *", - CodeExpression( - f'PyCapsule_GetPointer(owner_capsule, "{self._allocatable_holder_capsule_name(type_name)}")' - ), + CodeExpression(f'PyCapsule_GetPointer(owner_capsule, "{capsule_name}")'), ), CExpressionStatement(CodeExpression("Py_DECREF(owner_capsule)")), CIf(CodeExpression("owner_address == NULL"), body=(CReturn(CodeExpression("NULL")),)), @@ -2848,95 +2847,15 @@ def _pointer_holder_presence_method(self, derived: DerivedTypePlan) -> CFunction ), ) - def _pointer_holder_field_functions( - self, - derived: DerivedTypePlan, - field: DerivedFieldPlan, - ) -> tuple[CFunction, ...]: - """Build pointer holder field functions from the supplied completed binding records; emitted nodes only project completed binding actions.""" - scalar = PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name) - if field.access is DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW: - getter = self._derived_private_method( - self._pointer_holder_field_method_name(derived, field, "get"), - ( - *self._pointer_holder_owner_nodes(derived.backend_symbol, setter=False), - *self._scalar_storage_view_nodes( - f"{self._pointer_holder_field_bridge_name(derived, field, 'get')}(owner_address)", - field.semantic_type_name, - None, - "owner_obj", - ), - ), - ) - elif field.access is not DerivedFieldAccessMechanism.SCALAR_VALUE: - raise ValueError(f"Unsupported pointer-holder field for {field.owner_path!r}: {field.access.value}") - else: - getter = self._derived_private_method( - self._pointer_holder_field_method_name(derived, field, "get"), - ( - *self._pointer_holder_owner_nodes(derived.backend_symbol, setter=False), - CDeclaration( - "value", - scalar.c_spelling, - CodeExpression( - self._pointer_holder_field_bridge_name(derived, field, "get") + "(owner_address)" - ), - ), - CReturn(CodeExpression(self._scalar_result_expression(scalar, "&value"))), - ), - ) - if field.setter_action is not SetterAction.WRITE_THROUGH: - return (getter,) - setter = self._derived_private_method( - self._pointer_holder_field_method_name(derived, field, "set"), - ( - *self._pointer_holder_owner_nodes(derived.backend_symbol, setter=True), - CDeclaration("value", scalar.c_spelling), - self._scalar_field_unpack_statement(field, scalar, "value_obj", "value"), - CExpressionStatement( - CodeExpression( - f"{self._pointer_holder_field_bridge_name(derived, field, 'set')}(owner_address, value)" - ) - ), - CExpressionStatement(CodeExpression("Py_RETURN_NONE")), - ), - ) - return getter, setter - def _pointer_holder_owner_nodes(self, type_name: str, *, setter: bool) -> tuple: - """Build pointer holder owner nodes from the supplied local lowering values; emitted nodes only project completed binding actions.""" - declarations: tuple = (CDeclaration("owner_obj", "PyObject *"),) - if setter: - declarations = (*declarations, CDeclaration("value_obj", "PyObject *")) - parse = 'if (!PyArg_ParseTuple(args, "OO", &owner_obj, &value_obj)) return NULL' - else: - parse = 'if (!PyArg_ParseTuple(args, "O", &owner_obj)) return NULL' - return ( - *declarations, - CExpressionStatement(CodeExpression(parse)), - CDeclaration( - "owner_capsule", - "PyObject *", - CodeExpression('prik_getattr_interned(owner_obj, &prik_name_prik_capsule, "_prik_capsule")'), - ), - CIf(CodeExpression("owner_capsule == NULL"), body=(CReturn(CodeExpression("NULL")),)), - CDeclaration( - "owner_address", - "void *", - CodeExpression( - f'PyCapsule_GetPointer(owner_capsule, "{self._pointer_holder_capsule_name(type_name)}")' - ), - ), - CExpressionStatement(CodeExpression("Py_DECREF(owner_capsule)")), - CIf(CodeExpression("owner_address == NULL"), body=(CReturn(CodeExpression("NULL")),)), - ) + """Parse property arguments and extract one exact pointer-holder capsule.""" + return self._holder_owner_nodes(self._pointer_holder_capsule_name(type_name), setter=setter) def _module_derived_presence_method(self, variable: ModuleVariablePlan) -> CFunction: """Reject stale field access after native deallocation or nullification.""" name = self._module_derived_presence_method_name(variable) body = ( - CDeclaration("owner_obj", "PyObject *"), - CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &owner_obj)) return NULL')), + *self._owner_argument_nodes(), CIf( CodeExpression(f"!{self._module_derived_presence_bridge_name(variable)}()"), body=( @@ -3020,17 +2939,11 @@ def _direct_scalar_field_functions(self, derived, field) -> tuple[CFunction, ... def _direct_scalar_storage_field_functions(self, derived, field) -> tuple[CFunction, ...]: """Lend a stored scalar field's storage and copy assigned values into it.""" - getter = self._derived_private_method( + getter = self._scalar_storage_field_getter( + field, self._derived_field_method_name(derived, field, "get"), - ( - *self._derived_owner_address_nodes(derived), - *self._scalar_storage_view_nodes( - f"{self._derived_field_bridge_name(derived, field, 'get')}(owner_address)", - field.semantic_type_name, - field.character_length if field.string_element else None, - "owner_obj", - ), - ), + self._derived_owner_address_nodes(derived), + f"{self._derived_field_bridge_name(derived, field, 'get')}(owner_address)", ) setter = ( self._direct_string_field_setter(derived, field) @@ -3042,25 +2955,39 @@ def _direct_scalar_storage_field_functions(self, derived, field) -> tuple[CFunct def _module_scalar_storage_member_functions(self, variable, member) -> tuple[CFunction, ...]: """Lend a module object's stored scalar member and copy assigned values into it.""" field = member.field - getter = self._derived_private_method( + getter = self._scalar_storage_field_getter( + field, self._module_member_method_name(variable, member, "get"), + self._owner_argument_nodes(), + f"{self._module_member_bridge_name(variable, member, 'get')}()", + ) + setter = ( + self._module_string_member_setter(variable, member) + if field.string_element + else self._module_scalar_member_setter(variable, member) + ) + return self._optional_field_functions(getter, setter) + + def _scalar_storage_field_getter( + self, + field: DerivedFieldPlan, + method: str, + owner_nodes: tuple, + address_call: str, + ) -> CFunction: + """Lend one stored scalar field as a live view kept alive by its owner.""" + return self._derived_private_method( + method, ( - CDeclaration("owner_obj", "PyObject *"), - CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &owner_obj)) return NULL')), + *owner_nodes, *self._scalar_storage_view_nodes( - f"{self._module_member_bridge_name(variable, member, 'get')}()", + address_call, field.semantic_type_name, field.character_length if field.string_element else None, "owner_obj", ), ), ) - setter = ( - self._module_string_member_setter(variable, member) - if field.string_element - else self._module_scalar_member_setter(variable, member) - ) - return self._optional_field_functions(getter, setter) def _direct_scalar_descriptor_field_functions(self, derived, field) -> tuple[CFunction, ...]: """Lend a scalar allocatable or pointer field's storage and assign through it.""" @@ -3083,17 +3010,8 @@ def _module_scalar_descriptor_member_functions(self, variable, member) -> tuple[ self._module_member_bridge_name(variable, member, "set"), self._module_member_method_name(variable, member, "get"), self._module_member_method_name(variable, member, "set"), - owner_nodes=( - CDeclaration("owner_obj", "PyObject *"), - CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &owner_obj)) return NULL')), - ), - owner_value_nodes=( - CDeclaration("owner_obj", "PyObject *"), - CDeclaration("value_obj", "PyObject *"), - CExpressionStatement( - CodeExpression('if (!PyArg_ParseTuple(args, "OO", &owner_obj, &value_obj)) return NULL') - ), - ), + owner_nodes=self._owner_argument_nodes(), + owner_value_nodes=self._owner_argument_nodes(value=True), leading_arguments=(), ) @@ -3278,8 +3196,7 @@ def _direct_native_handle_field_getter( ) -> CFunction: """Create one parent-retaining Phase 7 handle for an address-backed field.""" body = ( - CDeclaration("owner_obj", "PyObject *"), - CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &owner_obj)) return NULL')), + *self._owner_argument_nodes(), *self._field_handle_factory_nodes(derived, field, "owner_obj"), ) return self._derived_private_method(self._derived_field_method_name(derived, field, "get"), body) @@ -3349,8 +3266,7 @@ def _module_ordinary_array_member_getter( ) -> CFunction: """Create a live NumPy view over one plain-module fixed array member.""" body = ( - CDeclaration("owner_obj", "PyObject *"), - CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &owner_obj)) return NULL')), + *self._owner_argument_nodes(), *self._borrowed_array_view_nodes( member.field, self._module_member_bridge_name(variable, member, "get"), @@ -3366,8 +3282,7 @@ def _module_native_handle_member_getter( ) -> CFunction: """Create one parent-retaining handle for a plain-module field path.""" body = ( - CDeclaration("owner_obj", "PyObject *"), - CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &owner_obj)) return NULL')), + *self._owner_argument_nodes(), *self._field_handle_factory_nodes((variable, member), member.field, "owner_obj"), ) return self._derived_private_method(self._module_member_method_name(variable, member, "get"), body) @@ -3380,8 +3295,7 @@ def _module_string_member_getter( """Copy one fixed plain-module string member into Python storage.""" length = self._fixed_string_field_length(member.field) body = ( - CDeclaration("owner_obj", "PyObject *"), - CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &owner_obj)) return NULL')), + *self._owner_argument_nodes(), CDeclaration(f"value[{length + 1}]", _character_c_type(member.field.semantic_type_name)), CExpressionStatement(CodeExpression(f"{self._module_member_bridge_name(variable, member, 'get')}(value)")), CExpressionStatement(CodeExpression(_terminator("value", length, member.field.semantic_type_name))), @@ -3399,11 +3313,7 @@ def _module_string_member_setter( if field.setter_action is not SetterAction.WRITE_THROUGH: return None body = ( - CDeclaration("owner_obj", "PyObject *"), - CDeclaration("value_obj", "PyObject *"), - CExpressionStatement( - CodeExpression('if (!PyArg_ParseTuple(args, "OO", &owner_obj, &value_obj)) return NULL') - ), + *self._owner_argument_nodes(value=True), *self._fixed_string_field_input_nodes(field, "value_obj"), CExpressionStatement(CodeExpression(f"{self._module_member_bridge_name(variable, member, 'set')}(value)")), *self._text_release_nodes(field.semantic_type_name), @@ -3623,11 +3533,7 @@ def _module_ordinary_array_member_setter( if field.setter_action is not SetterAction.WRITE_THROUGH: return None body = ( - CDeclaration("owner_obj", "PyObject *"), - CDeclaration("value_obj", "PyObject *"), - CExpressionStatement( - CodeExpression('if (!PyArg_ParseTuple(args, "OO", &owner_obj, &value_obj)) return NULL') - ), + *self._owner_argument_nodes(value=True), *self._ordinary_array_field_input_nodes(field, "value_obj", "value_array"), CExpressionStatement( CodeExpression(f"{self._module_member_bridge_name(variable, member, 'set')}(PyArray_DATA(value_array))") @@ -3733,8 +3639,7 @@ def _module_scalar_member_getter( """Return module scalar member getter from the supplied completed binding records; this helper preserves the selected binding behavior.""" scalar = PrimitiveScalarTypeRegistry.type_for(member.field.semantic_type_name) body = ( - CDeclaration("owner_obj", "PyObject *"), - CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &owner_obj)) return NULL')), + *self._owner_argument_nodes(), CDeclaration( "value", scalar.c_spelling, @@ -3755,11 +3660,7 @@ def _module_scalar_member_setter( return None scalar = PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name) body = ( - CDeclaration("owner_obj", "PyObject *"), - CDeclaration("value_obj", "PyObject *"), - CExpressionStatement( - CodeExpression('if (!PyArg_ParseTuple(args, "OO", &owner_obj, &value_obj)) return NULL') - ), + *self._owner_argument_nodes(value=True), CDeclaration("value", scalar.c_spelling), self._scalar_field_unpack_statement(field, scalar, "value_obj", "value"), CExpressionStatement(CodeExpression(f"{self._module_member_bridge_name(variable, member, 'set')}(value)")), @@ -3829,8 +3730,7 @@ def _module_nested_member_getter( if field.derived is None: raise ValueError(f"Nested module member {field.owner_path!r} has no derived handoff") body = ( - CDeclaration("owner_obj", "PyObject *"), - CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &owner_obj)) return NULL')), + *self._owner_argument_nodes(), *self._borrowed_derived_wrapper_nodes( field.derived.type_identity, "Py_None", @@ -3851,11 +3751,7 @@ def _module_nested_member_setter( if field.setter_action is not SetterAction.WRITE_THROUGH or field.derived is None: return None body = ( - CDeclaration("owner_obj", "PyObject *"), - CDeclaration("value_obj", "PyObject *"), - CExpressionStatement( - CodeExpression('if (!PyArg_ParseTuple(args, "OO", &owner_obj, &value_obj)) return NULL') - ), + *self._owner_argument_nodes(value=True), *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( @@ -3937,22 +3833,33 @@ def _derived_private_method(self, name: str, body: tuple) -> CFunction: body=body, ) + @staticmethod + def _owner_argument_nodes(*, value: bool = False) -> tuple: + """Parse a private field method's owner, and the assigned value for a setter.""" + if not value: + return ( + CDeclaration("owner_obj", "PyObject *"), + CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &owner_obj)) return NULL')), + ) + return ( + CDeclaration("owner_obj", "PyObject *"), + CDeclaration("value_obj", "PyObject *"), + CExpressionStatement( + CodeExpression('if (!PyArg_ParseTuple(args, "OO", &owner_obj, &value_obj)) return NULL') + ), + ) + def _derived_owner_address_nodes(self, derived: DerivedTypePlan) -> tuple: """Extract one checked opaque parent address from a live wrapper.""" return ( - CDeclaration("owner_obj", "PyObject *"), - CExpressionStatement(CodeExpression('if (!PyArg_ParseTuple(args, "O", &owner_obj)) return NULL')), + *self._owner_argument_nodes(), *self._derived_address_from_object_nodes(derived.backend_symbol, "owner_obj", "owner"), ) def _derived_owner_and_value_nodes(self, derived: DerivedTypePlan) -> tuple: """Extract one checked parent address and Python setter value.""" return ( - CDeclaration("owner_obj", "PyObject *"), - CDeclaration("value_obj", "PyObject *"), - CExpressionStatement( - CodeExpression('if (!PyArg_ParseTuple(args, "OO", &owner_obj, &value_obj)) return NULL') - ), + *self._owner_argument_nodes(value=True), *self._derived_address_from_object_nodes(derived.backend_symbol, "owner_obj", "owner"), ) @@ -8221,85 +8128,71 @@ def _string_replacement_allocation_nodes( ) @staticmethod - def _required_wide_string_validation_nodes( + def _required_string_validation_nodes( plan: ArgumentTransferPlan, names: _CArgumentNames, payload_name: str, ) -> tuple[CExpressionStatement, ...]: - """Measure one str in four-byte characters; the call buffer converts it.""" + """Return shared required-string type, encoding, NUL, and length checks. + + One-byte characters borrow the str's UTF-8 bytes; four-byte characters + are only measured here, and the call buffer converts them. + """ + name = plan.binding.python_name + wide = character_width(plan.semantic_type_name) == 4 nodes = [ CExpressionStatement( CodeExpression( f"if (!PyUnicode_Check({names.object_name})) {{ " f'PyErr_Format(PyExc_TypeError, "Expected an argument of type str for argument ' - f"{plan.binding.python_name}. Received \", " + f"{name}. Received \", " f"Py_TYPE({names.object_name})->tp_name); return NULL; }}" ) ), - CExpressionStatement( - CodeExpression( - f"if (prik_character_text({names.object_name}, {int(bool(plan.character_allows_embedded_nul))}, " - f'"{plan.binding.python_name}", &{payload_name}, &{names.length_name}, 4) < 0) return NULL' - ) - ), ] - if plan.character_length is not None: + if wide: nodes.append( CExpressionStatement( CodeExpression( - f"if ({names.length_name} != {plan.character_length}) {{ " - f'PyErr_SetString(PyExc_TypeError, "Argument {plan.binding.python_name} must contain ' - f'exactly {plan.character_length} characters"); return NULL; }}' + f"if (prik_character_text({names.object_name}, {int(bool(plan.character_allows_embedded_nul))}, " + f'"{name}", &{payload_name}, &{names.length_name}, 4) < 0) return NULL' ) ) ) - return tuple(nodes) - - def _required_string_validation_nodes( - self, - plan: ArgumentTransferPlan, - names: _CArgumentNames, - payload_name: str, - ) -> tuple[CExpressionStatement, ...]: - """Return shared required-string type, encoding, NUL, and length checks.""" - width = character_width(plan.semantic_type_name) - if width == 4: - return self._required_wide_string_validation_nodes(plan, names, payload_name) - nodes = [ - CExpressionStatement( - CodeExpression( - f"if (!PyUnicode_Check({names.object_name})) {{ " - f'PyErr_Format(PyExc_TypeError, "Expected an argument of type str for argument ' - f"{plan.binding.python_name}. Received \", " - f"Py_TYPE({names.object_name})->tp_name); return NULL; }}" - ) - ), - CExpressionStatement( - CodeExpression(f"{payload_name} = PyUnicode_AsUTF8AndSize({names.object_name}, &{names.length_name})") - ), - CExpressionStatement(CodeExpression(f"if ({payload_name} == NULL) return NULL")), - *( - () - if plan.character_allows_embedded_nul - else ( + else: + nodes.extend( + ( CExpressionStatement( CodeExpression( - f"if ((Py_ssize_t)strlen({payload_name}) != {names.length_name}) {{ " - f'PyErr_SetString(PyExc_TypeError, "Argument {plan.binding.python_name} cannot contain ' - 'embedded NUL"); return NULL; }' + f"{payload_name} = PyUnicode_AsUTF8AndSize({names.object_name}, &{names.length_name})" + ) + ), + CExpressionStatement(CodeExpression(f"if ({payload_name} == NULL) return NULL")), + *( + () + if plan.character_allows_embedded_nul + else ( + CExpressionStatement( + CodeExpression( + f"if ((Py_ssize_t)strlen({payload_name}) != {names.length_name}) {{ " + f'PyErr_SetString(PyExc_TypeError, "Argument {name} cannot contain ' + 'embedded NUL"); return NULL; }' + ) + ), ) ), ) - ), - ] + ) fixed_length = plan.character_length if fixed_length is not None: + measure = ( + f"contain exactly {fixed_length} characters" if wide else f"encode to exactly {fixed_length} bytes" + ) nodes.append( CExpressionStatement( CodeExpression( f"if ({names.length_name} != {fixed_length}) {{ " - f'PyErr_SetString(PyExc_TypeError, "Argument {plan.binding.python_name} must encode to ' - f'exactly {fixed_length} bytes"); return NULL; }}' + f'PyErr_SetString(PyExc_TypeError, "Argument {name} must {measure}"); return NULL; }}' ) ) ) diff --git a/prik/codegen/fortran/bridge.py b/prik/codegen/fortran/bridge.py index 0f4034e11..821b970ae 100644 --- a/prik/codegen/fortran/bridge.py +++ b/prik/codegen/fortran/bridge.py @@ -7726,7 +7726,13 @@ def _allocatable_holder_field_procedure_entries(self, plan: ModulePlan) -> tuple for procedure in self._planned_support_procedures( f"{derived.owner_path}.{field.name}", "field:allocatable:", - self._allocatable_holder_field_procedures(derived, field), + self._holder_field_procedures( + derived, + field, + "allocatable", + self._allocatable_holder_type_name(derived.backend_symbol), + self._allocatable_holder_field_bridge_name, + ), ) ) @@ -7739,7 +7745,13 @@ def _pointer_holder_field_procedure_entries(self, plan: ModulePlan) -> tuple[For for procedure in self._planned_support_procedures( f"{derived.owner_path}.{field.name}", "field:pointer:", - self._pointer_holder_field_procedures(derived, field), + self._holder_field_procedures( + derived, + field, + "pointer", + self._pointer_holder_type_name(derived.backend_symbol), + self._pointer_holder_field_bridge_name, + ), ) ) @@ -7771,84 +7783,41 @@ def _pointer_holder_field_types(self, plan: ModulePlan) -> tuple[DerivedTypePlan self._bridge_pointer_holder_field_owner_paths, ) - def _allocatable_holder_field_procedures( + def _holder_field_procedures( self, derived: DerivedTypePlan, field: DerivedFieldPlan, + holder: str, + holder_type: str, + bridge_name, ) -> tuple[FortranFunction, ...]: - """Lower scalar fields through the typed holder selected by policy.""" - scalar = PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name) - holder_type = self._allocatable_holder_type_name(derived.backend_symbol) - getter_name = self._allocatable_holder_field_bridge_name(derived, field, "get") - if field.access is DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW: - getter = self._scalar_storage_getter( - getter_name, - f"owner%value%{field.native_name}", - parameters=(FortranParameter("owner_address", "type(c_ptr)", ("value",)),), - declarations=(FortranDeclaration("owner", f"type({holder_type})", ("pointer",)),), - prologue=(self._derived_owner_association(),), - ) - elif field.access is not DerivedFieldAccessMechanism.SCALAR_VALUE: - raise ValueError(f"Unsupported allocatable-holder field for {field.owner_path!r}: {field.access.value}") - else: - getter = FortranFunction( - name=getter_name, - parameters=(FortranParameter("owner_address", "type(c_ptr)", ("value",)),), - result_name="result", - result_type=scalar.fortran_spelling, - bind_name=getter_name, - declarations=(FortranDeclaration("owner", f"type({holder_type})", ("pointer",)),), - body=( - self._derived_owner_association(), - FortranAssignment("result", CodeExpression(f"owner%value%{field.native_name}")), - ), - ) - if field.setter_action is not SetterAction.WRITE_THROUGH: - return (getter,) - setter_name = self._allocatable_holder_field_bridge_name(derived, field, "set") - setter = FortranFunction( - name=setter_name, - parameters=( - FortranParameter("owner_address", "type(c_ptr)", ("value",)), - FortranParameter("value", scalar.fortran_spelling, ("value",)), - ), - bind_name=setter_name, - declarations=(FortranDeclaration("owner", f"type({holder_type})", ("pointer",)),), - body=( - self._derived_owner_association(), - FortranAssignment(f"owner%value%{field.native_name}", CodeExpression("value")), - ), - is_subroutine=True, - ) - return getter, setter + """Read and write one scalar field of the object an allocatable or pointer holder holds. - def _pointer_holder_field_procedures( - self, - derived: DerivedTypePlan, - field: DerivedFieldPlan, - ) -> tuple[FortranFunction, ...]: - """Lower scalar fields through a pointer holder without owning its target.""" + Both holders reach the object as ``owner%value``; what differs is only + the holder type and the procedure names, which the caller supplies. + """ scalar = PrimitiveScalarTypeRegistry.type_for(field.semantic_type_name) - holder_type = self._pointer_holder_type_name(derived.backend_symbol) - getter_name = self._pointer_holder_field_bridge_name(derived, field, "get") + owner_parameter = FortranParameter("owner_address", "type(c_ptr)", ("value",)) + owner_declaration = FortranDeclaration("owner", f"type({holder_type})", ("pointer",)) + getter_name = bridge_name(derived, field, "get") if field.access is DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW: getter = self._scalar_storage_getter( getter_name, f"owner%value%{field.native_name}", - parameters=(FortranParameter("owner_address", "type(c_ptr)", ("value",)),), - declarations=(FortranDeclaration("owner", f"type({holder_type})", ("pointer",)),), + parameters=(owner_parameter,), + declarations=(owner_declaration,), prologue=(self._derived_owner_association(),), ) elif field.access is not DerivedFieldAccessMechanism.SCALAR_VALUE: - raise ValueError(f"Unsupported pointer-holder field for {field.owner_path!r}: {field.access.value}") + raise ValueError(f"Unsupported {holder}-holder field for {field.owner_path!r}: {field.access.value}") else: getter = FortranFunction( name=getter_name, - parameters=(FortranParameter("owner_address", "type(c_ptr)", ("value",)),), + parameters=(owner_parameter,), result_name="result", result_type=scalar.fortran_spelling, bind_name=getter_name, - declarations=(FortranDeclaration("owner", f"type({holder_type})", ("pointer",)),), + declarations=(owner_declaration,), body=( self._derived_owner_association(), FortranAssignment("result", CodeExpression(f"owner%value%{field.native_name}")), @@ -7856,15 +7825,12 @@ def _pointer_holder_field_procedures( ) if field.setter_action is not SetterAction.WRITE_THROUGH: return (getter,) - setter_name = self._pointer_holder_field_bridge_name(derived, field, "set") + setter_name = bridge_name(derived, field, "set") setter = FortranFunction( name=setter_name, - parameters=( - FortranParameter("owner_address", "type(c_ptr)", ("value",)), - FortranParameter("value", scalar.fortran_spelling, ("value",)), - ), + parameters=(owner_parameter, FortranParameter("value", scalar.fortran_spelling, ("value",))), bind_name=setter_name, - declarations=(FortranDeclaration("owner", f"type({holder_type})", ("pointer",)),), + declarations=(owner_declaration,), body=( self._derived_owner_association(), FortranAssignment(f"owner%value%{field.native_name}", CodeExpression("value")), diff --git a/prik/planning/entrypoints.py b/prik/planning/entrypoints.py index f16231558..a8e2e102f 100644 --- a/prik/planning/entrypoints.py +++ b/prik/planning/entrypoints.py @@ -656,49 +656,47 @@ def _field_operations( owner_parameter=owner_parameter, ) + def _field_operation(self, owner, field, route, owner_path, action, parameters, result=None): + """Plan one field accessor entrypoint of one owner route.""" + return self._operation( + owner_path, + f"field:{route}:{action}", + self._field_symbol(owner, field, route, action), + parameters, + result, + ) + + def _character_buffer(self, entity, intent): + """Return the fixed-width character buffer of one field or module variable, passed by address.""" + return self._value( + "value", + NativeEntrypointABIValueKind.CHARACTER, + pointer_depth=1, + const=intent == "in", + character_length=entity.character_length, + semantic_type_name=entity.semantic_type_name, + intent=intent, + ) + def _scalar_field_operations(self, owner, field, route, owner_path, *, owner_parameter): parameters = (self._opaque_parameter("owner", fortran_name="owner_address"),) if owner_parameter else () - result = ( - self._opaque_result() - if field.object_kind is ObjectKind.DERIVED_TYPE - else self._scalar_result(field.semantic_type_name) + derived = field.object_kind is ObjectKind.DERIVED_TYPE + result = self._opaque_result() if derived else self._scalar_result(field.semantic_type_name) + getter = self._field_operation(owner, field, route, owner_path, "get", parameters, result) + if field.setter_action is not SetterAction.WRITE_THROUGH: + return (getter,) + value = ( + self._opaque_parameter("value", fortran_name="value_address") + if derived + else self._scalar_parameter(field.semantic_type_name) ) - operations = [ - self._operation( - owner_path, - f"field:{route}:get", - self._field_symbol(owner, field, route, "get"), - parameters, - result, - ) - ] - if field.setter_action is SetterAction.WRITE_THROUGH: - value = ( - self._opaque_parameter("value", fortran_name="value_address") - if field.object_kind is ObjectKind.DERIVED_TYPE - else self._scalar_parameter(field.semantic_type_name) - ) - operations.append( - self._operation( - owner_path, - f"field:{route}:set", - self._field_symbol(owner, field, route, "set"), - (*parameters, value), - ) - ) - return tuple(operations) + return getter, self._field_operation(owner, field, route, owner_path, "set", (*parameters, value)) def _nested_module_field_operations(self, owner, field, route, owner_path): if field.setter_action is not SetterAction.WRITE_THROUGH: return () - return ( - self._operation( - owner_path, - f"field:{route}:set", - self._field_symbol(owner, field, route, "set"), - (self._opaque_parameter("value", fortran_name="value_address"),), - ), - ) + value = self._opaque_parameter("value", fortran_name="value_address") + return (self._field_operation(owner, field, route, owner_path, "set", (value,)),) def _scalar_storage_field_operations(self, owner, field, route, owner_path, owner_parameter): """Plan the storage-address getter and value setter of a stored scalar field. @@ -707,102 +705,37 @@ def _scalar_storage_field_operations(self, owner, field, route, owner_path, owne number by value, or a character in a buffer of the declared width. """ owner_values = (self._opaque_parameter("owner", fortran_name="owner_address"),) if owner_parameter else () - operations = [ - self._operation( - owner_path, - f"field:{route}:get", - self._field_symbol(owner, field, route, "get"), - owner_values, - self._opaque_result(), - ) - ] - if field.setter_action is SetterAction.WRITE_THROUGH: - value = ( - self._value( - "value", - NativeEntrypointABIValueKind.CHARACTER, - pointer_depth=1, - const=True, - character_length=field.character_length, - semantic_type_name=field.semantic_type_name, - intent="in", - ) - if field.string_element - else self._scalar_parameter(field.semantic_type_name) - ) - operations.append( - self._operation( - owner_path, - f"field:{route}:set", - self._field_symbol(owner, field, route, "set"), - (*owner_values, value), - ) - ) - return tuple(operations) + getter = self._field_operation(owner, field, route, owner_path, "get", owner_values, self._opaque_result()) + if field.setter_action is not SetterAction.WRITE_THROUGH: + return (getter,) + value = ( + self._character_buffer(field, "in") + if field.string_element + else self._scalar_parameter(field.semantic_type_name) + ) + return getter, self._field_operation(owner, field, route, owner_path, "set", (*owner_values, value)) def _scalar_descriptor_field_operations(self, owner, field, route, owner_path, owner_parameter): """Plan the current-storage getter and status-reporting setter of a scalar descriptor field.""" owner_values = (self._opaque_parameter("owner", fortran_name="owner_address"),) if owner_parameter else () width = (self._int64_parameter("length", reference=True, intent="out"),) if field.string_element else () - operations = [ - self._operation( - owner_path, - f"field:{route}:get", - self._field_symbol(owner, field, route, "get"), - (*owner_values, *width), - self._opaque_result(), - ) - ] - if field.setter_action is SetterAction.WRITE_THROUGH: - values, result = self._scalar_descriptor_setter_signature(field.string_element, field.semantic_type_name) - operations.append( - self._operation( - owner_path, - f"field:{route}:set", - self._field_symbol(owner, field, route, "set"), - (*owner_values, *values), - result, - ) - ) - return tuple(operations) + getter = self._field_operation( + owner, field, route, owner_path, "get", (*owner_values, *width), self._opaque_result() + ) + if field.setter_action is not SetterAction.WRITE_THROUGH: + return (getter,) + values, result = self._scalar_descriptor_setter_signature(field.string_element, field.semantic_type_name) + return getter, self._field_operation(owner, field, route, owner_path, "set", (*owner_values, *values), result) def _string_field_operations(self, owner, field, route, owner_path, owner_parameter): owner_values = (self._opaque_parameter("owner", fortran_name="owner_address"),) if owner_parameter else () - output = self._value( - "value", - NativeEntrypointABIValueKind.CHARACTER, - pointer_depth=1, - character_length=field.character_length, - semantic_type_name=field.semantic_type_name, - intent="out", + getter = self._field_operation( + owner, field, route, owner_path, "get", (*owner_values, self._character_buffer(field, "out")) ) - operations = [ - self._operation( - owner_path, - f"field:{route}:get", - self._field_symbol(owner, field, route, "get"), - (*owner_values, output), - ) - ] - if field.setter_action is SetterAction.WRITE_THROUGH: - value = self._value( - "value", - NativeEntrypointABIValueKind.CHARACTER, - pointer_depth=1, - const=True, - character_length=field.character_length, - semantic_type_name=field.semantic_type_name, - intent="in", - ) - operations.append( - self._operation( - owner_path, - f"field:{route}:set", - self._field_symbol(owner, field, route, "set"), - (*owner_values, value), - ) - ) - return tuple(operations) + if field.setter_action is not SetterAction.WRITE_THROUGH: + return (getter,) + setter_values = (*owner_values, self._character_buffer(field, "in")) + return getter, self._field_operation(owner, field, route, owner_path, "set", setter_values) def _ordinary_array_field_operations(self, owner, field, route, owner_path, owner_parameter): owner_values = (self._opaque_parameter("owner", fortran_name="owner_address"),) if owner_parameter else () @@ -1218,16 +1151,7 @@ def _primary_module_variable_operations(self, variable): elif variable.bridge.native_getter_action is ModuleGetterAction.CHARACTER_VALUE: # A character value has no by-value C ABI, so it copies out # through the same fixed-width buffer a character field uses. - parameters = ( - self._value( - "value", - NativeEntrypointABIValueKind.CHARACTER, - pointer_depth=1, - character_length=variable.character_length, - semantic_type_name=variable.semantic_type_name, - intent="out", - ), - ) + parameters = (self._character_buffer(variable, "out"),) result = self._void_result() else: parameters = () @@ -1260,16 +1184,7 @@ def _module_setter_signature(self, variable): """ assignment = variable.binding.native_assignment if assignment is AssignmentMode.CHARACTER_COPY: - value = self._value( - "value", - NativeEntrypointABIValueKind.CHARACTER, - pointer_depth=1, - const=True, - character_length=variable.character_length, - semantic_type_name=variable.semantic_type_name, - intent="in", - ) - return (value,), None + return (self._character_buffer(variable, "in"),), None if assignment not in {AssignmentMode.ALLOCATING_COPY, AssignmentMode.TARGET_COPY}: return (self._scalar_parameter(variable.semantic_type_name),), None return self._scalar_descriptor_setter_signature( From 362153b6b7a413de3918c51fd25f4126f4caec75 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 27 Sep 2026 17:34:35 +0100 Subject: [PATCH 09/11] codex: bring the README example up to date with class and field spellings Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7cd1df86c..4f281de9b 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ end module points 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 @@ -119,9 +119,9 @@ Want a more Pythonic API? Edit `contracts/points.pyi`: ```python from prik.contracts import Addr, Arg, Float64, Pass, bind, native_call -class point: - x: Float64 = 0.0 - y: Float64 = 0.0 +class Point: + x: Float64[()] = 0.0 + y: Float64[()] = 0.0 def __init__(self, *, x: Float64 = 0.0, y: Float64 = 0.0) -> None: ... @@ -153,7 +153,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 From dc9b2bf1d89685a95cc5dc714dd1ad21303e9f92 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 27 Sep 2026 17:49:25 +0100 Subject: [PATCH 10/11] codex: document method binding targets and verify README contract replay --- CHANGELOG.md | 2 + README.md | 15 ++--- docs/user/guide/wrapping-derived-types.md | 20 +++--- .../pyi-contracts/functions-and-classes.md | 13 ++-- prik/policy/completion.py | 2 +- .../end_to_end/test_source_build_modes.py | 62 +++++++++++++++++++ 6 files changed, 93 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82f741f62..7570a95e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ release tags add a leading `v` to the package version. ## Unreleased +- The README and derived-type contract guides show how to bind a class method + to a module procedure, including when the two share a name. - Fortran `character(kind=selected_char_kind('ISO_10646'))` (UCS-4) values are supported through the new `UString` contract type, which takes every form `String` does and maps storage to NumPy `U` instead of `S`. A diff --git a/README.md b/README.md index 4f281de9b..20a6490e7 100644 --- a/README.md +++ b/README.md @@ -129,15 +129,16 @@ class Point: @native_call([Pass(), Addr(Arg(0)), Addr(Arg(1))]) def translate(self, dx: Float64, dy: Float64) -> None: ... + @bind("norm_squared") @native_call([Pass()]) def norm_squared(self) -> Float64: ... ``` -`@bind("move")` is needed because `translate` has a different Python name. -`norm_squared` needs no `@bind`: matching Python and native names select the -same procedure. `Pass()` supplies the receiver (`self`) to the native call; -`Addr(Arg(...))` passes the remaining arguments by address as required by the -native calling convention. +A method without `@bind` calls the type-bound procedure of its own name. Both +methods here call module procedures instead, so each names one with `@bind`: +`translate` calls `move`, and `norm_squared` calls `norm_squared`. `Pass()` +supplies the receiver (`self`) to the native call; `Addr(Arg(...))` passes the +remaining arguments by address as required by the native calling convention. Build from the contract: @@ -328,8 +329,8 @@ print(stats.extremes(values)) # (np.float64(1.0), np.float64(5.0)) `count` never appears in the Python signature — the contract derives it from the array — and the two output pointers come back as a tuple instead of being passed in. `mean` and `extremes` need no `@bind` because their Python and C -names match; use `@bind("native_name")` only when they differ. The same rule -applies to Fortran contracts. +names match; use `@bind("native_name")` only when they differ. Fortran module +functions follow the same rule. ### What C support covers diff --git a/docs/user/guide/wrapping-derived-types.md b/docs/user/guide/wrapping-derived-types.md index fd2334187..1fc1575b0 100644 --- a/docs/user/guide/wrapping-derived-types.md +++ b/docs/user/guide/wrapping-derived-types.md @@ -343,27 +343,31 @@ end subroutine increment ``` ```python -item = counters.counter(value=np.int32(4)) +item = counters.Counter(value=np.int32(4)) item.increment(np.int32(3)) print(item.value) # 7 ``` -The method mutates the existing `counter`; it does not replace the Python -object. +The method mutates the existing `Counter`; it does not replace the Python +object. In a contract, a method without `@bind` calls the type-bound procedure +of its own name, and `@bind("Counter.increment")` names a type-bound procedure +whose name differs from the method's. ### Expose a Module Procedure as a Method The `move(item, dx, dy)` procedure from this page's example can remain a -module-level function and also become `point.move(dx, dy)`. +module-level function and also become `Point.move(dx, dy)`. -`Pass()` supplies `self` to the native call. `Arg(i)` refers to a visible -Python argument. Add the method to the existing `point` class while keeping -the module declaration: +`@bind("move")` makes the method call the module procedure `move` rather than a +type-bound procedure. `Pass()` supplies `self` to the native call. `Arg(i)` +refers to a visible Python argument. Add the method to the existing `Point` +class while keeping the module declaration: ```python -from prik.contracts import Addr, Arg, Float64, Pass, native_call +from prik.contracts import Addr, Arg, Float64, Pass, bind, native_call class Point: + @bind("move") @native_call([Pass(), Addr(Arg(0)), Addr(Arg(1))]) def move(self, dx: Float64, dy: Float64) -> None: ... diff --git a/docs/user/reference/pyi-contracts/functions-and-classes.md b/docs/user/reference/pyi-contracts/functions-and-classes.md index 21fa9bbd9..5933a0afd 100644 --- a/docs/user/reference/pyi-contracts/functions-and-classes.md +++ b/docs/user/reference/pyi-contracts/functions-and-classes.md @@ -18,23 +18,26 @@ Keep the module procedure declaration and add a method that calls it. `Pass()` places `self` in the native argument list: ```python -from prik.contracts import Addr, Arg, Float64, Pass, native_call, private +from prik.contracts import Addr, Arg, Float64, Pass, bind, native_call, private -class point: +class Point: + @bind("move") @native_call([Pass(), Addr(Arg(0))]) def move(self, dx: Float64) -> None: ... @private @native_call([Arg(0), Addr(Arg(1))]) -def move(item: point, dx: Float64) -> None: ... +def move(item: Point, dx: Float64) -> None: ... ``` Python exposes `item.move(dx)`. The private module declaration keeps the native procedure information but is not callable from Python. Remove `@private` when both `move(item, dx)` and `item.move(dx)` should be public. -The method name normally selects the native procedure. Add `@bind("move")` to -the method when its Python name differs from that procedure. +`@bind("move")` selects the module procedure, even when the method has the +same name. A method without `@bind` calls a type-bound procedure of its own +name. To call a differently named type-bound procedure, use a class-qualified +target such as `@bind("Point.translate")`. ## Edit an Overload Set diff --git a/prik/policy/completion.py b/prik/policy/completion.py index 0e402fec6..58c493e53 100644 --- a/prik/policy/completion.py +++ b/prik/policy/completion.py @@ -811,7 +811,7 @@ def _class_overload_native_target(procedure: models.SemanticFunction) -> str: def _uses_type_bound_invocation(method: models.SemanticMethod, class_name: str) -> bool: - """Restore generated-.pyi type-bound calls when their private root target is absent.""" + """Select type-bound calls for implicit or class-qualified method targets.""" if method.is_static: return False 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 2fc2ef3db..c2e6cc575 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 @@ -286,6 +286,68 @@ def test_documented_readme_points_example_builds_and_imports(tmp_path: Path): sys.modules.pop("geometry.points", None) sys.modules.pop("geometry", None) + contracts = tmp_path / "contracts" + subprocess.run( + [sys.executable, "-m", "prik", "generate", "--pyi", str(source), "--out", str(contracts)], + capture_output=True, + text=True, + check=True, + cwd=tmp_path, + ) + contract = contracts / "points.pyi" + generated = contract.read_text(encoding="utf-8") + assert "class Point:" in generated + assert "x: Float64[()] = 0.0" in generated + methods = """ + @bind("move") + @native_call([Pass(), Addr(Arg(0)), Addr(Arg(1))]) + def translate(self, dx: Float64, dy: Float64) -> None: ... + + @bind("norm_squared") + @native_call([Pass()]) + def norm_squared(self) -> Float64: ... +""" + module_move = "\n@native_call([Arg(0), Addr(Arg(1)), Addr(Arg(2))])" + assert generated.count(module_move) == 1 + contract.write_text( + "from prik.contracts import Pass, bind\n" + generated.replace(module_move, f"\n{methods}{module_move}"), + encoding="utf-8", + ) + + edited = tmp_path / "edited" + edited.mkdir() + subprocess.run( + [ + sys.executable, + "-m", + "prik", + str(contracts / "__init__.pyi"), + "--native-fortran-sources", + str(source), + "--out", + "geometry", + "--out-dir", + str(edited / "build" / "geometry"), + ], + capture_output=True, + text=True, + check=True, + cwd=edited, + ) + + sys.path.insert(0, str(edited)) + try: + geometry = importlib.import_module("geometry") + item = geometry.points.Point(x=np.float64(3.0), y=np.float64(4.0)) + item.translate(np.float64(1.0), np.float64(-2.0)) + assert item.x == np.float64(4.0) + assert item.y == np.float64(2.0) + assert item.norm_squared() == np.float64(20.0) + finally: + sys.path.remove(str(edited)) + sys.modules.pop("geometry.points", None) + sys.modules.pop("geometry", None) + def test_source_build_result_records_structured_native_plan(tmp_path: Path): """The internal preprocessor still builds an importable wrapper whose result records its native plan.""" From 99b9d37fbb5deb92cedd65ba19cd457b19c67e9d Mon Sep 17 00:00:00 2001 From: said Date: Sun, 27 Sep 2026 18:00:03 +0100 Subject: [PATCH 11/11] codex: consolidate edited method binding runtime coverage --- .../end_to_end/test_source_build_modes.py | 62 ------------------- .../method_and_constructor/fclasses_f90.pyi | 12 +++- .../end_to_end/test_edited_class_surfaces.py | 6 +- 3 files changed, 15 insertions(+), 65 deletions(-) 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 c2e6cc575..2fc2ef3db 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 @@ -286,68 +286,6 @@ def test_documented_readme_points_example_builds_and_imports(tmp_path: Path): sys.modules.pop("geometry.points", None) sys.modules.pop("geometry", None) - contracts = tmp_path / "contracts" - subprocess.run( - [sys.executable, "-m", "prik", "generate", "--pyi", str(source), "--out", str(contracts)], - capture_output=True, - text=True, - check=True, - cwd=tmp_path, - ) - contract = contracts / "points.pyi" - generated = contract.read_text(encoding="utf-8") - assert "class Point:" in generated - assert "x: Float64[()] = 0.0" in generated - methods = """ - @bind("move") - @native_call([Pass(), Addr(Arg(0)), Addr(Arg(1))]) - def translate(self, dx: Float64, dy: Float64) -> None: ... - - @bind("norm_squared") - @native_call([Pass()]) - def norm_squared(self) -> Float64: ... -""" - module_move = "\n@native_call([Arg(0), Addr(Arg(1)), Addr(Arg(2))])" - assert generated.count(module_move) == 1 - contract.write_text( - "from prik.contracts import Pass, bind\n" + generated.replace(module_move, f"\n{methods}{module_move}"), - encoding="utf-8", - ) - - edited = tmp_path / "edited" - edited.mkdir() - subprocess.run( - [ - sys.executable, - "-m", - "prik", - str(contracts / "__init__.pyi"), - "--native-fortran-sources", - str(source), - "--out", - "geometry", - "--out-dir", - str(edited / "build" / "geometry"), - ], - capture_output=True, - text=True, - check=True, - cwd=edited, - ) - - sys.path.insert(0, str(edited)) - try: - geometry = importlib.import_module("geometry") - item = geometry.points.Point(x=np.float64(3.0), y=np.float64(4.0)) - item.translate(np.float64(1.0), np.float64(-2.0)) - assert item.x == np.float64(4.0) - assert item.y == np.float64(2.0) - assert item.norm_squared() == np.float64(20.0) - finally: - sys.path.remove(str(edited)) - sys.modules.pop("geometry.points", None) - sys.modules.pop("geometry", None) - def test_source_build_result_records_structured_native_plan(tmp_path: Path): """The internal preprocessor still builds an importable wrapper whose result records its native plan.""" diff --git a/tests/fortran/infrastructure/semantic_pyi/contracts/functions_and_classes/end_to_end/fixtures/edited_contracts/method_and_constructor/fclasses_f90.pyi b/tests/fortran/infrastructure/semantic_pyi/contracts/functions_and_classes/end_to_end/fixtures/edited_contracts/method_and_constructor/fclasses_f90.pyi index 3f0819869..51ee52e05 100644 --- a/tests/fortran/infrastructure/semantic_pyi/contracts/functions_and_classes/end_to_end/fixtures/edited_contracts/method_and_constructor/fclasses_f90.pyi +++ b/tests/fortran/infrastructure/semantic_pyi/contracts/functions_and_classes/end_to_end/fixtures/edited_contracts/method_and_constructor/fclasses_f90.pyi @@ -1,5 +1,5 @@ -# Intentional difference: reuse one module procedure as a direct constructor, -# a renamed method, and a public module function. +# Intentional difference: reuse module procedures as a direct constructor, +# renamed and same-name methods, and public module functions. from prik.contracts import Addr, Arg, Float64, Pass, bind, native_call @@ -12,6 +12,14 @@ class vector: @native_call([Addr(Arg(0)), Pass(), Addr(Arg(1))]) def shift(self, dx: Float64, dy: Float64) -> None: ... + @bind("shift_vector") + @native_call([Addr(Arg(0)), Pass(), Addr(Arg(1))]) + def shift_vector(self, dx: Float64, dy: Float64) -> None: ... + + @bind("magnitude") + @native_call([Pass()]) + def magnitude(self) -> Float64: ... + x: Float64 y: Float64 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 cdec34766..9bce2871a 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 @@ -47,8 +47,12 @@ def test_module_procedure_is_reused_by_bound_constructor_method_and_public_funct value.shift(np.float64(1.0), np.float64(-1.0)) assert (value.x, value.y) == (np.float64(3.0), np.float64(2.0)) + value.shift_vector(np.float64(1.0), np.float64(0.0)) + assert (value.x, value.y) == (np.float64(4.0), np.float64(2.0)) + module.shift_vector(np.float64(2.0), value, np.float64(4.0)) - assert (value.x, value.y) == (np.float64(5.0), np.float64(6.0)) + assert (value.x, value.y) == (np.float64(6.0), np.float64(6.0)) + assert value.magnitude() == pytest.approx(np.sqrt(72.0)) assert "shift(dx, dy) -> None" in module.vector.shift.__doc__