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 cadc008f8..7570a95e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ 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 + character kind given by `selected_char_kind('ASCII')` or `'DEFAULT'` is now + 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 + 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 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. - 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 @@ -14,7 +41,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 @@ -123,10 +151,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/README.md b/README.md index 4e2592aec..20a6490e7 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: ... @@ -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: @@ -153,7 +154,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 @@ -235,11 +236,10 @@ 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` 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 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/) @@ -329,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/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 a75331be3..882d467ae 100644 --- a/docs/user/guide/allocatables.md +++ b/docs/user/guide/allocatables.md @@ -17,9 +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 - `Allocatable[T[...]]` handles. + scalar allocatable module variable or derived-type field returns a live + 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 @@ -84,14 +84,15 @@ 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` -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 +`Allocatable[Float64]()` is not supported. Reading a scalar module variable or +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 58fe297bf..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 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. @@ -77,10 +78,11 @@ 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 -when associated, or `None` otherwise. Assigning to the attribute copies the -value into the current target; it raises `ValueError` when the pointer is not +`Pointer[Float64]()` is not supported. Reading a scalar module variable or +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/strings.md b/docs/user/guide/strings.md index b8408cc59..46e8a297c 100644 --- a/docs/user/guide/strings.md +++ b/docs/user/guide/strings.md @@ -250,6 +250,37 @@ 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. 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')`, 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 A scalar `character` dummy may carry the `allocatable` or `pointer` attribute, diff --git a/docs/user/guide/wrapping-derived-types.md b/docs/user/guide/wrapping-derived-types.md index c0a0532f0..1fc1575b0 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 @@ -214,7 +214,16 @@ 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 + 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 @@ -223,7 +232,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`). @@ -292,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))]) @@ -334,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/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 70bb9dc11..fa64dbbbd 100644 --- a/docs/user/language-support/feature-matrix.md +++ b/docs/user/language-support/feature-matrix.md @@ -59,18 +59,18 @@ 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 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. | +| 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-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/docs/user/reference/pyi-format.md b/docs/user/reference/pyi-format.md index a633df633..06efd9361 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. | @@ -858,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. @@ -1106,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 f78fb3cc4..b7c013a19 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -43,7 +43,6 @@ DerivedWriteback, DirectResultABI, ModuleObjectAccessMechanism, - ModuleStorageAddressMechanism, ModuleGetterAction, NativeArrayDescriptorAttribute, NativeArrayDescriptorKind, @@ -68,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, @@ -100,6 +103,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, @@ -222,7 +226,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.", @@ -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.""" @@ -728,28 +793,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, @@ -1208,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)),) @@ -2640,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) @@ -2652,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) @@ -2689,43 +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.""" - if field.access is not DerivedFieldAccessMechanism.SCALAR_VALUE: - raise ValueError(f"Unsupported allocatable-holder field for {field.owner_path!r}: {field.access.value}") + """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 = 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)" - ), - ), + getter_call = f"{bridge_name(derived, field, 'get')}(owner_address)" + if field.access is DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW: + 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 {holder}-holder field for {field.owner_path!r}: {field.access.value}") + else: + 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")), ), ) @@ -2733,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 *", @@ -2751,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")),)), @@ -2782,79 +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.""" - 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)"), - ), - 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=( @@ -2882,6 +2883,8 @@ 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, } try: @@ -2900,6 +2903,8 @@ 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, } try: @@ -2932,6 +2937,137 @@ 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._scalar_storage_field_getter( + field, + self._derived_field_method_name(derived, field, "get"), + 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) + 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._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, + ( + *owner_nodes, + *self._scalar_storage_view_nodes( + address_call, + field.semantic_type_name, + field.character_length if field.string_element else None, + "owner_obj", + ), + ), + ) + + 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=self._owner_argument_nodes(), + owner_value_nodes=self._owner_argument_nodes(value=True), + 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( @@ -3060,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) @@ -3075,12 +3210,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) @@ -3098,6 +3233,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) @@ -3130,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"), @@ -3147,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) @@ -3161,12 +3295,11 @@ 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')), - CDeclaration(f"value[{length + 1}]", "char"), + *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(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) @@ -3180,13 +3313,10 @@ 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), CExpressionStatement(CodeExpression("Py_RETURN_NONE")), ) return self._derived_private_method(self._module_member_method_name(variable, member, "set"), body) @@ -3201,36 +3331,12 @@ 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", + field.semantic_type_name, ) def _field_handle_backend_release_nodes(self, field: DerivedFieldPlan, prefix: str) -> tuple: @@ -3427,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))") @@ -3537,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, @@ -3559,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)")), @@ -3633,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", @@ -3655,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( @@ -3741,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"), ) @@ -4159,7 +4262,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( @@ -4655,7 +4758,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 @@ -5196,7 +5299,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 @@ -5449,8 +5552,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")),)), @@ -5478,7 +5582,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), @@ -5701,7 +5805,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): @@ -6194,92 +6298,126 @@ 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 _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. + 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``. - The descriptor may be reallocated or reassociated after this read, so - Python writes through the setter rather than through the view. + A character lends its declared width as fixed-width bytes. """ - 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" + if character_length is not None: + view = ( + 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: - numpy_type, width = PrimitiveScalarTypeRegistry.type_for(plan.semantic_type_name).array_numpy_type, "0" + 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 writable rank-zero view. + + The view is valid until the descriptor is reallocated or reassociated; + assigning the attribute always reaches the current storage. + """ 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 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. Like an array view, it + is valid until native code reallocates, deallocates, or reassociates + that storage. + """ + if character: + 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 ()))) + 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(semantic_type_name) if character else ()), + CDeclaration( + "result", + "PyObject *", + CodeExpression( + 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), + ) + @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 @@ -6302,11 +6440,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)") ), @@ -6331,10 +6476,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))), ), ), ) @@ -6354,37 +6499,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")), ), ), @@ -6479,9 +6598,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})" @@ -6874,28 +6993,75 @@ 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. + """ + release: tuple = () + if character: + 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: - 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,65 +7069,94 @@ 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)), *release, *failures) @staticmethod - def _module_setter_text_nodes(plan: ModuleVariablePlan, name: str) -> tuple: - """Encode one Python string, requiring the declared width when there is one.""" - width = plan.character_length + 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:] + 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)), + ), + ), + 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 ( - CIf( - CodeExpression("!PyUnicode_Check(value_obj)"), - body=( - CExpressionStatement( - CodeExpression(f'PyErr_SetString(PyExc_TypeError, "Expected str for module variable {name}")') - ), - CReturn(CodeExpression("-1")), - ), - ), + type_check, 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)), ), ), ) + @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 () @@ -7811,9 +8006,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}") @@ -7880,7 +8075,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; }}" ) ), ) @@ -7892,11 +8088,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=( @@ -7910,54 +8124,75 @@ def _string_replacement_allocation_nodes( CReturn(CodeExpression("NULL")), ), ), - CExpressionStatement( - CodeExpression(f"memcpy({names.value_name}, {source_name}, (size_t){names.length_name})") - ), - CExpressionStatement(CodeExpression(f"{names.value_name}[{names.length_name}] = '\\0'")), + *fill, ) + @staticmethod def _required_string_validation_nodes( - self, plan: ArgumentTransferPlan, 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. + + 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"{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 ( + ] + if wide: + nodes.append( + CExpressionStatement( + CodeExpression( + 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' + ) + ) + ) + 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; }}' ) ) ) @@ -8015,12 +8250,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})" ) ), @@ -8909,6 +9148,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) @@ -9049,9 +9290,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: @@ -9298,13 +9541,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 \", " @@ -9315,7 +9560,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; }}' ) @@ -9820,9 +10065,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", @@ -10020,7 +10269,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")), @@ -10033,7 +10282,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, @@ -10179,7 +10428,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) @@ -10525,8 +10778,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: @@ -10596,7 +10850,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")), @@ -10624,7 +10880,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")), @@ -13181,7 +13437,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"), @@ -13197,7 +13453,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: @@ -14299,13 +14555,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, @@ -15899,7 +16150,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: @@ -16579,8 +16831,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 6cf5b15a2..00dee46bf 100644 --- a/prik/codegen/docstrings.py +++ b/prik/codegen/docstrings.py @@ -15,6 +15,7 @@ ArrayPythonLayout, ScalarActualMode, ClassConstructorKind, + DerivedFieldAccessMechanism, EntrypointOptionalityAction, ModuleGetterAction, NativeArrayDescriptorKind, @@ -41,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 = { @@ -55,6 +59,7 @@ "Complex64": "complex64", "Complex128": "complex128", "String": "str", + "UString": "str", } # An aliased array reports the width its Fortran elements really occupy. NumPy @@ -85,7 +90,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." ), } @@ -512,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 ""), @@ -569,6 +576,10 @@ 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: lines.append(" Assignment writes through to native storage.") elif field.setter_action is SetterAction.REJECT_REPLACEMENT: @@ -1013,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 b4c7d92ad..821b970ae 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 @@ -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, @@ -82,6 +86,7 @@ FortranTypeDefinition, FortranUse, ) +from prik.planning.planner import has_scalar_view_fields, requires_address_capture from prik.planning.models import ( ArrayHandoffPlan, ArgumentTransferPlan, @@ -121,6 +126,57 @@ _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"} + + +# 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. + + ``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. @@ -250,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) @@ -262,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 @@ -513,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] @@ -600,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, ( @@ -784,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: @@ -1262,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: @@ -2488,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), ), ), ), @@ -3294,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 @@ -3302,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.""" @@ -3317,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( @@ -3396,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: @@ -3407,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( @@ -3427,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)") @@ -3467,34 +3529,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})")), ), ), ), @@ -3519,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=( @@ -3537,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=( @@ -3569,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 ) @@ -3678,16 +3780,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_view_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 +3815,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 +3830,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, @@ -3781,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")), @@ -3791,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)"), @@ -3874,57 +3975,57 @@ 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( + 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: + 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",)), ) - 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", @@ -3932,27 +4033,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 () @@ -4203,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, ...]: @@ -4326,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") @@ -4789,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"), ) @@ -5727,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: @@ -5748,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) @@ -5819,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), @@ -5881,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",)) @@ -5919,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 ( @@ -5953,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( @@ -6055,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, ...]: @@ -6343,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(:)"), ), ) @@ -6495,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 @@ -6503,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) @@ -6647,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", @@ -6680,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( @@ -6758,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=( @@ -6884,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(:)"), ), ) @@ -6996,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})"), @@ -7099,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", @@ -7114,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( @@ -7125,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: @@ -7144,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( @@ -7211,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. @@ -7226,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})"), @@ -7254,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 @@ -7282,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=( @@ -7302,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))), ), ), ) @@ -7599,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, + ), ) ) @@ -7612,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, + ), ) ) @@ -7644,40 +7783,54 @@ 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.""" - if field.access is not DerivedFieldAccessMechanism.SCALAR_VALUE: - raise ValueError(f"Unsupported allocatable-holder field for {field.owner_path!r}: {field.access.value}") + """Read and write one scalar field of the object an allocatable or pointer holder holds. + + 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._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}")), - ), - ) + 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=(owner_parameter,), + declarations=(owner_declaration,), + prologue=(self._derived_owner_association(),), + ) + elif field.access is not DerivedFieldAccessMechanism.SCALAR_VALUE: + raise ValueError(f"Unsupported {holder}-holder field for {field.owner_path!r}: {field.access.value}") + else: + getter = FortranFunction( + name=getter_name, + parameters=(owner_parameter,), + result_name="result", + result_type=scalar.fortran_spelling, + bind_name=getter_name, + declarations=(owner_declaration,), + 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_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")), @@ -7686,98 +7839,192 @@ def _allocatable_holder_field_procedures( ) return getter, setter - def _pointer_holder_field_procedures( + def _direct_field_procedures( self, derived: DerivedTypePlan, 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}")), - ), + """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), ) - if field.setter_action is not SetterAction.WRITE_THROUGH: - return (getter,) - setter_name = self._pointer_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, + + 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), ) - return getter, setter + 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_field_procedures( - self, - 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.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 ())) + 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.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, + 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, @@ -7793,7 +8040,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)"), ), ), @@ -7822,7 +8069,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)"), ), ), @@ -7852,7 +8099,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)"), ), ), @@ -7878,7 +8125,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)"), ), ), @@ -8112,7 +8359,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, @@ -8124,7 +8371,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 ) @@ -8963,10 +9210,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 @@ -9014,7 +9261,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) @@ -9227,7 +9474,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",)), @@ -9278,11 +9525,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 ) @@ -9504,7 +9751,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) @@ -9526,7 +9777,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, @@ -9598,7 +9851,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: @@ -9607,7 +9860,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") @@ -9655,7 +9908,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( @@ -9881,6 +10134,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/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/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 3db24e3d9..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 ( @@ -177,6 +178,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 +888,8 @@ 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 ) @@ -898,7 +907,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 +924,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 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: @@ -926,6 +942,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. @@ -5463,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 c1e355c2f..a8e2e102f 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, ) @@ -630,11 +631,17 @@ 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) 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.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: @@ -649,85 +656,86 @@ 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. + + 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 () + 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 () + 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, - 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, - 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 () @@ -1143,15 +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, - intent="out", - ), - ) + parameters = (self._character_buffer(variable, "out"),) result = self._void_result() else: parameters = () @@ -1184,19 +1184,18 @@ 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, - 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 - 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..808db3190 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -37,6 +37,7 @@ DirectResultABI, ModuleGetterAction, ModuleObjectAccessMechanism, + ModuleStorageAddressMechanism, ModuleVariablePolicy, NativeArraySourceKind, OverloadPolicy, @@ -149,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 ( @@ -161,6 +165,47 @@ _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_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 + in {DerivedFieldAccessMechanism.SCALAR_STORAGE_VIEW, 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_view_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} @@ -204,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, } @@ -1680,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..58c493e53 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, @@ -808,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 @@ -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 4063f5aaf..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, ) @@ -355,25 +358,26 @@ 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, 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=_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 +388,52 @@ 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 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.storage_mode is StorageMode.ALIAS + ): + 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, ObjectKind.NUMPY_ARRAY: DerivedFieldAccessMechanism.ORDINARY_ARRAY_DESCRIPTOR, ObjectKind.DERIVED_TYPE: DerivedFieldAccessMechanism.NESTED_OBJECT, - }[object_kind] + }[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, - 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 _PARENT_RETAINING_FIELD_ACCESS: + return DerivedOwnerRetention.PARENT_WRAPPER + if object_kind in {ObjectKind.NUMPY_ARRAY, ObjectKind.DERIVED_TYPE}: return DerivedOwnerRetention.PARENT_WRAPPER return DerivedOwnerRetention.NONE @@ -687,7 +717,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 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 ) @@ -843,13 +873,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,13 +918,22 @@ 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 semantic_type.name not in _PLAN_PRIMITIVE_SCALAR_TYPES | {"String"}: + if access is DerivedFieldAccessMechanism.SCALAR_DESCRIPTOR_VIEW: + 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_SEMANTIC_TYPE_NAMES: + blockers.append(f"field {field.name!r} is not a primitive or character stored scalar") + 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_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: @@ -915,12 +955,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 +1003,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, @@ -1217,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 @@ -1295,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), @@ -1556,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 @@ -1618,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: @@ -1988,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 ] @@ -2047,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) ) ) ), @@ -2392,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. @@ -2412,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, ...]: @@ -2680,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: @@ -2939,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: @@ -2977,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 @@ -3023,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) @@ -3472,10 +3515,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 +3618,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 +3840,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,26 +4566,38 @@ 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 + 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, @@ -5791,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: @@ -6108,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, @@ -6161,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) @@ -6266,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) ) @@ -6325,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: @@ -6395,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, @@ -6447,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) @@ -6698,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") @@ -6752,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") @@ -6773,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) @@ -7026,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") @@ -7054,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) @@ -7066,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 ) @@ -7112,7 +7173,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 +7190,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 +7219,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 @@ -7177,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 @@ -7212,16 +7273,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) @@ -7257,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) @@ -7584,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"} ) @@ -7602,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) ) @@ -7626,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, @@ -7682,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) @@ -7697,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) @@ -7717,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), ) @@ -7886,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 @@ -7904,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( @@ -7935,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) @@ -7971,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/models.py b/prik/policy/models.py index 4a6b52428..803df1ff1 100644 --- a/prik/policy/models.py +++ b/prik/policy/models.py @@ -377,9 +377,20 @@ 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_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" NATIVE_ARRAY_HANDLE = "native_array_handle" diff --git a/prik/policy/ownership.py b/prik/policy/ownership.py index 01075767b..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 @@ -975,9 +978,9 @@ 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 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 +1014,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, @@ -1022,7 +1025,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 @@ -1031,18 +1035,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 + 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 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"): @@ -1913,7 +1922,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: @@ -1965,6 +1974,29 @@ 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 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) if facts.pointer and facts.rank == 0: @@ -2281,7 +2313,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 @@ -2612,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 b816aa3cd..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 @@ -1479,13 +1481,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 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") ) @staticmethod @@ -1850,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 fbfec2063..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 @@ -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,8 +167,20 @@ ("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", } +# 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 = { @@ -271,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]: @@ -363,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 []) } @@ -773,14 +801,14 @@ 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) 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 +1184,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, @@ -2737,8 +2765,20 @@ 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": + 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)" + ) + 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 @@ -2788,7 +2828,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``.""" @@ -4225,7 +4272,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 +4316,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]: @@ -4523,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}'." @@ -4706,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/prik/semantics/pyi2ir.py b/prik/semantics/pyi2ir.py index b5a16a688..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, @@ -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 @@ -1837,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 @@ -2048,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) @@ -2158,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, @@ -2376,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]) @@ -2390,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}, ) @@ -2596,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 @@ -2802,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) ): @@ -2837,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) @@ -2860,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, @@ -3062,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 @@ -3351,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" @@ -3806,14 +3821,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/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/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/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/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 new file mode 100644 index 000000000..91ba0a362 --- /dev/null +++ b/tests/fortran/derived_types/end_to_end/fixtures/native/fderived_scalar_descriptor_fields.f90 @@ -0,0 +1,52 @@ +module scalar_descriptor_fields + implicit none + + 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 + 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/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 2970597d2..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 @@ -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,99 @@ 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" + 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. + 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 _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", + { + "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) + _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 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 + + 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/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/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/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/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( 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_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/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/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__ 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) 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/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 d701412b6..f442611d3 100644 --- a/tests/fortran/strings/semantics/test_fortran_string_semantics.py +++ b/tests/fortran/strings/semantics/test_fortran_string_semantics.py @@ -1,6 +1,8 @@ """Tests split by stable ownership concept from `test_compile_time_values.py`.""" -from prik.semantics.fortran2ir import fortran_module_to_semantic_module +import pytest + +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 @@ -78,3 +80,80 @@ 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("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. + + 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 + function copy(text) result(out) + character(kind={kind}, len=4), intent(in) :: text + character(kind={kind}, len=4) :: out + out = text + end function copy +end module kinds +""" + ) + + func = get_function(fortran_module_to_semantic_module(parsed), "copy") + + 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 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