Strings¶
PRIK uses Python str for scalar character values.
Mutable character storage uses fixed-width NumPy bytes arrays.
The contract decides whether native mutation becomes a new str or changes
caller-owned storage.
Choose A String Boundary¶
| Contract | Python value | Native mutation |
|---|---|---|
String |
Variable-length str |
Returned only when projected |
String[8] |
str or rank-zero NumPy S8 array |
str returns a new value; array storage changes in place |
String[8][()] |
Rank-zero NumPy array with dtype S8 |
Visible in place |
String[8][count] |
NumPy bytes array with dtype S8 |
Visible in place |
Addr(String[8]) |
Integer address | Visible through caller-owned memory |
Use normal string and NumPy contracts by default. Raw addresses are an
advanced boundary covered later in the guide.
Returns[...] tells the wrapper to return the changed value of an argument.
Complete Example¶
The source, generated contract, edited contract, and Python calls below describe the same string boundaries. The results remain visible below the four views.
Fortran source¶
Create strings_api.f90:
module strings_api
implicit none
contains
subroutine edit_text(text)
character(len=8), intent(inout) :: text
text(1:1) = "X"
end subroutine edit_text
subroutine edit_buffer(text)
character(len=8), intent(inout) :: text
text(1:1) = "X"
end subroutine edit_buffer
function make_text() result(text)
character(len=8) :: text
text = "ready"
end function make_text
subroutine edit_labels(count, labels)
integer(4), intent(in) :: count
character(len=8), intent(inout) :: labels(count)
integer(4) :: index
do index = 1, count
labels(index)(1:1) = "X"
end do
end subroutine edit_labels
end module strings_api
Generated Contract¶
The generated contracts/strings/strings_api.pyi is:
from prik.contracts import Addr, Arg, Int32, Returns, String, native_call
def edit_text(
text: String[8]
) -> Returns["text", String[8]]: ...
def edit_buffer(
text: String[8]
) -> Returns["text", String[8]]: ...
def make_text() -> String[8]: ...
@native_call([Addr(Arg(0)), Arg(1)])
def edit_labels(
count: Int32,
labels: String[8][count]
) -> None: ...
Generate it:
python3 -m prik generate --pyi strings_api.f90 --out contracts/strings
Edited Contract¶
Only edit_buffer changes. Rank-zero storage makes native mutation visible in
place rather than returning a replacement value. The edited
contracts/strings/strings_api.pyi is:
from prik.contracts import Addr, Arg, Int32, Returns, String, native_call
def edit_text(
text: String[8]
) -> Returns["text", String[8]]: ...
def edit_buffer(text: String[8][()]) -> None: ...
def make_text() -> String[8]: ...
@native_call([Addr(Arg(0)), Arg(1)])
def edit_labels(
count: Int32,
labels: String[8][count],
) -> None: ...
Build from the edited contract and native source:
python3 -m prik contracts/strings/__init__.pyi \
--native-fortran-sources strings_api.f90 \
--out-dir build/strings
For the complete result-mapping rules, see Reorder Arguments and Project Outputs.
Immutable Values¶
String[8] accepts a Python str whose encoded length is exactly eight bytes,
or a rank-zero NumPy array with dtype S8. A str uses call-local storage;
the array supplies its own bytes to a reference dummy.
import sys
sys.path.insert(0, "build/strings")
from strings.strings_api import edit_text, make_text
original = "alpha "
changed = edit_text(original)
print(repr(original)) # 'alpha '
print(repr(changed)) # 'Xlpha '
print(repr(make_text())) # 'ready '
Python strings are immutable. Returns[...] copies the changed native buffer
into a new str. A rank-zero array passed to edit_text also changes in place.
Without a projection, only array mutation remains visible.
Mutable Scalar Storage¶
String[8][()] accepts a rank-zero NumPy bytes array.
Native writes change the same object.
import sys
import numpy as np
sys.path.insert(0, "build/strings")
from strings.strings_api import edit_buffer
buffer = np.array("alpha ", dtype="S8")
edit_buffer(buffer)
print(buffer[()]) # b'Xlpha '
The public value is bytes storage. Reading buffer[()] returns np.bytes_,
not str.
String Arrays¶
String arrays use fixed-width NumPy bytes dtypes. The dtype item size is the Fortran character length.
import sys
import numpy as np
sys.path.insert(0, "build/strings")
from strings.strings_api import edit_labels
labels = np.array([b"alpha ", b"beta "], dtype="S8")
edit_labels(np.int32(labels.size), labels)
print(labels) # [b'Xlpha ' b'Xeta ']
The wrapper checks rank, shape, dtype, and writeability before the call. Unicode and object arrays are rejected.
Result:
'alpha '
'Xlpha '
'ready '
b'Xlpha '
[b'Xlpha ' b'Xeta ']
Length And Encoding¶
String[8]requires exactly eight encoded bytes.Stringaccepts a runtime character length.- Fixed-width results retain trailing Fortran blanks.
- Embedded NUL bytes are rejected for scalar Python strings.
String[8][()]andString[8][count]require dtypeS8.- A dummy without
intentuses the conservativeintent(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 |
integer, parameter :: ucs4 = selected_char_kind('ISO_10646')
character(kind=ucs4, len=8) :: title
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<n> 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,
at a deferred length (character(len=:)) or a declared one
(character(len=8)). Every combination is supported, in every direction:
| Fortran dummy | Python surface |
|---|---|
intent(in) |
A str argument. |
intent(out) |
A returned str, or None when the procedure leaves it unallocated or unassociated. |
intent(inout) |
A str argument that also returns the value the procedure left behind, or None. |
| function result | A returned str, or None. |
The attribute never changes the Python surface, and it never changes how the
value crosses into native code — a scalar string is always a byte buffer and a
length. It changes only the storage PRIK builds inside the generated adapter,
because an allocatable or pointer dummy will not accept a plain temporary as
its actual argument.
An update keeps its str argument and adds a return value, because the native
procedure chooses the new value during the call and the caller's string cannot
hold it:
subroutine grow(value)
character(len=:), allocatable, intent(inout) :: value
if (allocated(value)) value = value // '!!!'
end subroutine grow
print(grow("ab")) # ab!!!
The Python string you pass is never modified; the reallocated value comes back
as the result. A procedure that deallocates the dummy returns None, which is
how you tell an unallocated result from an empty string:
print(drop("abc")) # None
print(repr(empty_out("abc"))) # ''
Pointer Dummies And Native Storage¶
A pointer dummy needs an associated actual argument, so PRIK allocates a
target for the call. What happens to that target afterwards is the native
procedure's decision, and PRIK follows it:
| The native procedure… | Python receives | PRIK's target |
|---|---|---|
| writes through the pointer | the edited value | freed after the call |
| leaves it alone | the value passed in | freed after the call |
| deallocates it | None |
already freed; not freed again |
| nullifies it | None |
orphaned by the procedure |
| reassociates it elsewhere | the new target's value | orphaned by the procedure |
PRIK copies the value out of whatever the dummy ends up holding. It then frees
the target it allocated for that call, but only while it can still prove the
dummy points at it. Anything else it leaves alone, because it cannot tell a
static target from a fresh allocation or from storage the library still owns.
Two consequences are worth planning for: a procedure that reassociates or
nullifies the dummy orphans the target PRIK allocated for that call, and a
procedure that returns a freshly allocated pointer each call leaks unless it
also frees it. Prefer an
allocatable dummy, whose release is unambiguous, when you control the Fortran
side.
Spelling Them In A Contract¶
In a semantic .pyi contract, the attribute is a native_call projection and
the length is the first subscription after String:
| Contract | Fortran |
|---|---|
String |
character(len=*) — the caller fixes the length |
String[8] |
character(len=8) — exactly eight encoded bytes |
String[:] |
character(len=:) — the length comes from allocation |
So the procedure above generates:
@native_call([Allocatable(Arg(0))])
def grow(value: String[:] | None) -> Returns["value", String[:]] | None: ...
Allocatable(...) and Pointer(...) carry the attribute, and they wrap the
argument, the projected output, or the result:
@native_call([Pointer(Arg(0))])
def edit(value: String[4] | None) -> Returns["value", String[4]] | None: ...
@native_call([], result=Allocatable(Return(0)))
def build() -> String[:] | None: ...
Arrays keep the length in that same first slot and add their shape second, as in
String[8][:] or Allocatable[String[:][:]]. Keep the native call and
storage declarations accurate when editing; Calls and
Results explains the shared
argument and result rules.
Next¶
- Continue with Wrapping Functions.
- Wrapping Subroutines for complete
intentand result-projection rules. - Raw Addresses for the advanced
Addr(String[n])boundary.