nativegate 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nativegate/__init__.py +1 -0
- nativegate/__main__.py +4 -0
- nativegate/buildinfo.py +344 -0
- nativegate/cli.py +2007 -0
- nativegate/config.py +991 -0
- nativegate/declared_invariants.py +565 -0
- nativegate/discovery.py +167 -0
- nativegate/driverbuild.py +626 -0
- nativegate/drivers/__init__.py +5 -0
- nativegate/drivers/cpp.py +616 -0
- nativegate/drivers/fortran.py +507 -0
- nativegate/generators/__init__.py +0 -0
- nativegate/generators/cmake_gen.py +101 -0
- nativegate/generators/docker_gen.py +614 -0
- nativegate/generators/error_gen.py +104 -0
- nativegate/generators/f2py_gen.py +91 -0
- nativegate/generators/gateway_gen.py +110 -0
- nativegate/generators/golden_gen.py +50 -0
- nativegate/generators/k8s_gen.py +212 -0
- nativegate/generators/mcp_gen.py +281 -0
- nativegate/generators/middleware_gen.py +717 -0
- nativegate/generators/pybind_gen.py +406 -0
- nativegate/generators/pyproject_gen.py +61 -0
- nativegate/generators/python_pkg_gen.py +1164 -0
- nativegate/generators/test_gen.py +160 -0
- nativegate/golden.py +747 -0
- nativegate/invariants.py +532 -0
- nativegate/ir.py +789 -0
- nativegate/lattice.py +350 -0
- nativegate/locking.py +216 -0
- nativegate/oracle.py +904 -0
- nativegate/parsers/__init__.py +0 -0
- nativegate/parsers/cpp.py +105 -0
- nativegate/parsers/cpp_ast.py +1652 -0
- nativegate/parsers/cpp_regex.py +812 -0
- nativegate/parsers/fixed_form.py +868 -0
- nativegate/parsers/fortran.py +157 -0
- nativegate/parsers/fortran_fparser.py +1116 -0
- nativegate/parsers/fortran_regex.py +686 -0
- nativegate/preprocess.py +335 -0
- nativegate/structural_invariants.py +762 -0
- nativegate/suggest.py +208 -0
- nativegate/templates/__init__.py +20 -0
- nativegate/templates/golden_test_template.py +248 -0
- nativegate/wire.py +438 -0
- nativegate-0.1.0.dist-info/METADATA +547 -0
- nativegate-0.1.0.dist-info/RECORD +50 -0
- nativegate-0.1.0.dist-info/WHEEL +5 -0
- nativegate-0.1.0.dist-info/entry_points.txt +3 -0
- nativegate-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
"""Regex/line-oriented Fortran reader (design.md section 8).
|
|
2
|
+
|
|
3
|
+
This was nativegate's only Fortran front end; `fortran_fparser.py` is the real
|
|
4
|
+
parse tree that supersedes it, and `fortran.py` picks between them. It is kept
|
|
5
|
+
because it has no third-party dependency and because reproducing its exact
|
|
6
|
+
behaviour is how the fparser2 backend's parity is measured.
|
|
7
|
+
|
|
8
|
+
It does NOT scan the whole file into a tree. Each name in `expose.functions:`
|
|
9
|
+
drives one targeted regex search for that routine's
|
|
10
|
+
`function ... end function` / `subroutine ... end subroutine` block, and
|
|
11
|
+
everything else in the file is left untouched — so a 20,000-line template
|
|
12
|
+
with one exposed routine costs roughly the same as a 200-line one.
|
|
13
|
+
|
|
14
|
+
That property turns out to be a premature optimisation at the sizes nativegate
|
|
15
|
+
actually sees: fparser2 parses every deck in libraries/petro whole in well
|
|
16
|
+
under 0.15s each. It is documented here as a description of this reader, not
|
|
17
|
+
as a constraint on the replacement.
|
|
18
|
+
|
|
19
|
+
`expose.functions:` is still REQUIRED for Fortran (there is no
|
|
20
|
+
"expose everything" fallback like C++ has), under both backends.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from ..config import ExposeConfig
|
|
29
|
+
from ..discovery import is_fixed_form
|
|
30
|
+
from ..ir import (
|
|
31
|
+
FunctionDef,
|
|
32
|
+
ModuleIR,
|
|
33
|
+
NativeTypeError,
|
|
34
|
+
Parameter,
|
|
35
|
+
SkippedSymbol,
|
|
36
|
+
map_fortran_type,
|
|
37
|
+
)
|
|
38
|
+
from ..preprocess import expand_includes
|
|
39
|
+
from . import fixed_form
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _strip_inline_comment(
|
|
43
|
+
line: str, open_quote: str | None = None
|
|
44
|
+
) -> tuple[str, str | None]:
|
|
45
|
+
"""Drop a free-form `!` comment, respecting character literals.
|
|
46
|
+
|
|
47
|
+
Returns (text, quote_still_open). `open_quote` carries a character literal
|
|
48
|
+
that a previous physical line left open across a `&` continuation.
|
|
49
|
+
|
|
50
|
+
`write (*,*) 'flow rate ! per day'` has no comment on it, and cutting at
|
|
51
|
+
the bare `!` would truncate the statement mid-string.
|
|
52
|
+
|
|
53
|
+
Doubled quotes (`'don''t'`) need no special case: the second quote closes
|
|
54
|
+
the literal and the third reopens it, which leaves the parity — the only
|
|
55
|
+
thing this needs — correct either way.
|
|
56
|
+
"""
|
|
57
|
+
quote = open_quote
|
|
58
|
+
for index, char in enumerate(line):
|
|
59
|
+
if quote is not None:
|
|
60
|
+
if char == quote:
|
|
61
|
+
quote = None
|
|
62
|
+
elif char in "'\"":
|
|
63
|
+
quote = char
|
|
64
|
+
elif char == "!":
|
|
65
|
+
return line[:index], None
|
|
66
|
+
# A literal still open at end of line is only meaningful when the line
|
|
67
|
+
# continues; the caller decides, and passes it back in on the next line.
|
|
68
|
+
return line, quote
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def normalize_free_form(source: str) -> str:
|
|
72
|
+
"""Collapse free-form source into one logical statement per line.
|
|
73
|
+
|
|
74
|
+
Free-form Fortran continues a statement by ending the line with `&`, and
|
|
75
|
+
a continuation line may re-open with its own leading `&` to mark exactly
|
|
76
|
+
where the statement resumes. Nothing downstream of here handles that: the
|
|
77
|
+
routine finder captures its argument list with `(?P<params>[^)]*)`, which
|
|
78
|
+
matches newlines, so a wrapped argument list
|
|
79
|
+
|
|
80
|
+
function tubing_bhp(wellhead_p, q_oil, q_water, q_gas, diameter, &
|
|
81
|
+
roughness, tvd, md, nseg) result(pbh)
|
|
82
|
+
|
|
83
|
+
yielded a parameter literally named "&\\n roughness". That name reached
|
|
84
|
+
the generated FastAPI router as `diameter: float, &` — a Python
|
|
85
|
+
SyntaxError, so the service could not even be imported, and the only
|
|
86
|
+
workarounds were to reformat the Fortran onto one line or hand-edit
|
|
87
|
+
generated code. Both mean touching source nativegate promises not to
|
|
88
|
+
touch.
|
|
89
|
+
|
|
90
|
+
This is the free-form counterpart of `fixed_form.normalize_fixed_form`
|
|
91
|
+
and exists for the same reason: dialect structure is normalized once, up
|
|
92
|
+
front, so no downstream pattern has to know about it.
|
|
93
|
+
"""
|
|
94
|
+
out: list[str] = []
|
|
95
|
+
pending = ""
|
|
96
|
+
# Carried across physical lines: a character literal may itself be split
|
|
97
|
+
# over a continuation (`'rate&` / `&! per day'`), and resetting the quote
|
|
98
|
+
# state per line made the `!` inside that still-open literal look like a
|
|
99
|
+
# comment, silently truncating the string.
|
|
100
|
+
open_quote: str | None = None
|
|
101
|
+
|
|
102
|
+
for raw in source.splitlines():
|
|
103
|
+
line, open_quote = _strip_inline_comment(raw, open_quote)
|
|
104
|
+
|
|
105
|
+
if pending:
|
|
106
|
+
# A whole-line comment is legal *between* continuation lines and
|
|
107
|
+
# does not end the statement. `_strip_inline_comment` reduces such
|
|
108
|
+
# a line to blank; flushing on it closed the statement early and
|
|
109
|
+
# truncated the argument list — so while a continuation is pending,
|
|
110
|
+
# a blank line is skipped rather than treated as the final segment.
|
|
111
|
+
if not line.strip():
|
|
112
|
+
continue
|
|
113
|
+
# A leading `&` marks where the statement resumes and is not part
|
|
114
|
+
# of it. Without one, the statement resumes at the first
|
|
115
|
+
# non-blank character.
|
|
116
|
+
resumed = line.lstrip()
|
|
117
|
+
line = resumed[1:] if resumed.startswith("&") else resumed
|
|
118
|
+
|
|
119
|
+
trimmed = line.rstrip()
|
|
120
|
+
if trimmed.endswith("&"):
|
|
121
|
+
pending += trimmed[:-1]
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
if pending:
|
|
125
|
+
out.append(pending + trimmed)
|
|
126
|
+
pending = ""
|
|
127
|
+
else:
|
|
128
|
+
# Leading whitespace is preserved: the declaration and routine
|
|
129
|
+
# patterns anchor with `^\s*`, and blank lines keep the source
|
|
130
|
+
# roughly line-aligned for error messages.
|
|
131
|
+
out.append(line)
|
|
132
|
+
|
|
133
|
+
# A trailing `&` with nothing after it is malformed Fortran; keep what was
|
|
134
|
+
# accumulated so the caller reports "routine not found" rather than
|
|
135
|
+
# silently dropping the last statement.
|
|
136
|
+
if pending:
|
|
137
|
+
out.append(pending)
|
|
138
|
+
|
|
139
|
+
return "\n".join(out)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_DECL_RE = re.compile(
|
|
143
|
+
r"^\s*(?P<type>real|integer|logical|character|complex|double\s+precision)"
|
|
144
|
+
r"(?:\s*\((?P<kind>[^)]*)\))?"
|
|
145
|
+
r"(?P<attrs>(?:\s*,\s*\w+(?:\([^)]*\))?)*)"
|
|
146
|
+
r"\s*::\s*(?P<names>[^\n!]+)",
|
|
147
|
+
re.IGNORECASE | re.MULTILINE,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
_INTENT_RE = re.compile(r"intent\s*\(\s*(in|out|inout)\s*\)", re.IGNORECASE)
|
|
151
|
+
|
|
152
|
+
# `optional` is an attribute in its own right and never a prefix of another
|
|
153
|
+
# one, so a word-boundary match against the attribute list is exact.
|
|
154
|
+
_OPTIONAL_RE = re.compile(r"\boptional\b", re.IGNORECASE)
|
|
155
|
+
|
|
156
|
+
# A derived-type (or polymorphic) declaration: `type(pvt_state) :: st`.
|
|
157
|
+
# Recognised deliberately — f2py cannot pass or return one, and the whole
|
|
158
|
+
# point of matching it is to say so rather than let the declaration lookup
|
|
159
|
+
# miss and fall through to a default. `type :: pvt_state` (a type
|
|
160
|
+
# *definition*) carries no parenthesis and is not matched here.
|
|
161
|
+
_DERIVED_DECL_RE = re.compile(
|
|
162
|
+
r"^\s*(?P<type>(?:type|class)\s*\(\s*[^)]*\))"
|
|
163
|
+
r"(?P<attrs>(?:\s*,\s*\w+(?:\([^)]*\))?)*)"
|
|
164
|
+
r"\s*::\s*(?P<names>[^\n!]+)",
|
|
165
|
+
re.IGNORECASE | re.MULTILINE,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# An intrinsic type word anywhere in a routine's return-type prefix
|
|
169
|
+
# (`real(8) function foo(...)`, `double precision function bar(...)`).
|
|
170
|
+
_PREFIX_TYPE_RE = re.compile(
|
|
171
|
+
r"\b(real|integer|logical|character|complex|double\s+precision)\b",
|
|
172
|
+
re.IGNORECASE,
|
|
173
|
+
)
|
|
174
|
+
_PREFIX_DERIVED_RE = re.compile(r"^\s*(?:type|class)\s*\(", re.IGNORECASE)
|
|
175
|
+
|
|
176
|
+
# The trailing name on `end module` is optional in the standard, and a module
|
|
177
|
+
# that omits it must still be recognised or every routine inside it is
|
|
178
|
+
# mis-attributed to the top level — which sends generate_init_py looking for
|
|
179
|
+
# the symbol where f2py has not put it.
|
|
180
|
+
_MODULE_BLOCK_RE = re.compile(
|
|
181
|
+
r"^[ \t]*module\s+(?P<name>\w+)[ \t]*$"
|
|
182
|
+
r".*?"
|
|
183
|
+
r"^[ \t]*end[ \t]+module(?:[ \t]+\w+)?[ \t]*$",
|
|
184
|
+
re.IGNORECASE | re.MULTILINE | re.DOTALL,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# A return-type prefix can be more than one token: `double precision function`,
|
|
188
|
+
# `real(kind=8) function`, `character*(*) function`. Allowing only a single
|
|
189
|
+
# `[\w()]+` token silently skipped every `DOUBLE PRECISION FUNCTION` in the
|
|
190
|
+
# file. `(?!function\b|subroutine\b)` keeps the optional prefix from eating
|
|
191
|
+
# the keyword itself when a routine has no return type; `end\b` is excluded
|
|
192
|
+
# for a related reason — once the argument list became optional below,
|
|
193
|
+
# `end subroutine reset` otherwise parsed as prefix "end" plus a fresh
|
|
194
|
+
# routine declaration, and every routine was discovered twice.
|
|
195
|
+
_TYPE_PREFIX = r"(?:(?!function\b|subroutine\b|end\b)[\w*()=]+[ \t]+)*"
|
|
196
|
+
|
|
197
|
+
# The argument list is optional: `subroutine reset` is legal and is the
|
|
198
|
+
# standard idiom for a routine that works purely through module variables or
|
|
199
|
+
# COMMON. Requiring "(" made such a routine invisible to discovery *and*
|
|
200
|
+
# unfindable by name, so this pattern and `_find_block` are fixed together —
|
|
201
|
+
# fixing only discovery converts a silent omission into a hard error.
|
|
202
|
+
# (fixed_form.py was already fixed for the same thing; this is the free-form
|
|
203
|
+
# half.)
|
|
204
|
+
_ROUTINE_DECL_RE = re.compile(
|
|
205
|
+
rf"^[ \t]*{_TYPE_PREFIX}(?:function|subroutine)\s+(?P<name>\w+)[ \t]*(?:\(|$)",
|
|
206
|
+
re.IGNORECASE | re.MULTILINE,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# `end`, `end function` and `end function foo` are all legal terminators. The
|
|
210
|
+
# bare form is the delicate one: `end` also closes `if`/`do`/`select`/`where`/
|
|
211
|
+
# `associate`/`block`/`type`/`interface`, so it is accepted only when the line
|
|
212
|
+
# is *exactly* `end` — which no construct terminator ever is, since they all
|
|
213
|
+
# carry their keyword and the one-word spellings are `endif`/`enddo`.
|
|
214
|
+
_END_BLOCK = r"^[ \t]*end(?:[ \t]+{keyword}(?:[ \t]+{name})?)?[ \t]*$"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class _RoutineSkipped(Exception):
|
|
218
|
+
"""A routine that was found and understood, but cannot be bound by f2py.
|
|
219
|
+
|
|
220
|
+
Raised out of extraction and turned into a `ModuleIR.skipped` entry by
|
|
221
|
+
`parse_source`. Defaulting an unresolvable type to `float` instead
|
|
222
|
+
produces a service that builds, imports, passes its smoke test and
|
|
223
|
+
returns a wrong number; this is the alternative.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
def __init__(self, reason: str) -> None:
|
|
227
|
+
super().__init__(reason)
|
|
228
|
+
self.reason = reason
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def list_routine_names(path: Path) -> list[str]:
|
|
232
|
+
"""Full-file scan for every `function`/`subroutine` name, for `quickstart`
|
|
233
|
+
on small/unfamiliar files where you don't yet know what to ask for by
|
|
234
|
+
name. NOT used by parse_source's targeted extraction — deliberately
|
|
235
|
+
separate so the "huge legacy template" path never accidentally pays for
|
|
236
|
+
a full scan (see module docstring).
|
|
237
|
+
|
|
238
|
+
Fixed-form source must be normalized first, and for the same reasons
|
|
239
|
+
parse_source normalizes it: a continued declaration
|
|
240
|
+
(`DOUBLE PRECISION FUNCTION FOO (A,\\n & B)`) spans two physical
|
|
241
|
+
lines, and a commented-out routine in column 1 looks real. Scanning raw
|
|
242
|
+
text with the free-form regex silently under-reports — against
|
|
243
|
+
libraries/petro it found 25 of 48 routines, missing every
|
|
244
|
+
`DOUBLE PRECISION FUNCTION`, so `quickstart` would generate a service
|
|
245
|
+
exposing half the API with no warning that anything was skipped.
|
|
246
|
+
"""
|
|
247
|
+
if is_fixed_form(path):
|
|
248
|
+
normalized = fixed_form.normalize_fixed_form(path.read_text())
|
|
249
|
+
return fixed_form.list_routine_names(normalized)
|
|
250
|
+
# Free-form needs its own normalization for the same reason: a routine
|
|
251
|
+
# whose argument list wraps has its `function` keyword and its name on one
|
|
252
|
+
# physical line but is only a single statement, and a commented-out
|
|
253
|
+
# routine must not be discovered.
|
|
254
|
+
return [
|
|
255
|
+
m.group("name")
|
|
256
|
+
for m in _ROUTINE_DECL_RE.finditer(normalize_free_form(path.read_text()))
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _find_block(source: str, keyword: str, name: str) -> re.Match | None:
|
|
261
|
+
"""Locate one `function`/`subroutine` block by name without parsing the rest of the file."""
|
|
262
|
+
end = _END_BLOCK.format(keyword=keyword, name=re.escape(name))
|
|
263
|
+
pattern = re.compile(
|
|
264
|
+
rf"^[ \t]*(?P<prefix>{_TYPE_PREFIX}){keyword}\s+{re.escape(name)}[ \t]*"
|
|
265
|
+
# No argument list at all is legal (`subroutine reset`).
|
|
266
|
+
rf"(?:\((?P<params>[^)]*)\))?"
|
|
267
|
+
rf"(?:\s*result\s*\(\s*(?P<result>\w+)\s*\))?[ \t]*$"
|
|
268
|
+
rf"(?P<body>.*?)"
|
|
269
|
+
rf"{end}",
|
|
270
|
+
re.IGNORECASE | re.MULTILINE | re.DOTALL,
|
|
271
|
+
)
|
|
272
|
+
return pattern.search(source)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _enclosing_fortran_module(source: str, match_start: int) -> str | None:
|
|
276
|
+
"""f2py nests routines defined inside `module X ... end module X` under X.<routine>
|
|
277
|
+
in the compiled extension (unlike bare/free routines, which land at the top level).
|
|
278
|
+
The generated Python __init__.py needs to know this to re-export correctly."""
|
|
279
|
+
for module_match in _MODULE_BLOCK_RE.finditer(source):
|
|
280
|
+
if module_match.start() <= match_start <= module_match.end():
|
|
281
|
+
return module_match.group("name")
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _parse_derived_declarations(body: str) -> dict[str, str]:
|
|
286
|
+
"""Map variable name -> native type spelling for every derived-type declaration.
|
|
287
|
+
|
|
288
|
+
These are collected precisely so they can be refused: f2py has no way to
|
|
289
|
+
pass or return a `type(...)`, and a missed lookup here used to fall
|
|
290
|
+
through to `float`.
|
|
291
|
+
"""
|
|
292
|
+
unbindable: dict[str, str] = {}
|
|
293
|
+
for decl in _DERIVED_DECL_RE.finditer(body):
|
|
294
|
+
spelling = re.sub(r"\s+", "", decl.group("type"))
|
|
295
|
+
for raw_name in decl.group("names").split(","):
|
|
296
|
+
var_name = raw_name.strip().split("(")[0].strip()
|
|
297
|
+
if var_name:
|
|
298
|
+
unbindable[var_name.lower()] = spelling
|
|
299
|
+
return unbindable
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _parse_declarations(body: str) -> dict[str, Parameter]:
|
|
303
|
+
"""Map declared variable name -> Parameter, for every `type, attrs :: names` line in body."""
|
|
304
|
+
declared: dict[str, Parameter] = {}
|
|
305
|
+
for decl in _DECL_RE.finditer(body):
|
|
306
|
+
try:
|
|
307
|
+
py_type = map_fortran_type(decl.group("type"))
|
|
308
|
+
except NativeTypeError:
|
|
309
|
+
raise
|
|
310
|
+
|
|
311
|
+
attrs = decl.group("attrs") or ""
|
|
312
|
+
intent_match = _INTENT_RE.search(attrs)
|
|
313
|
+
intent = intent_match.group(1).lower() if intent_match else "in"
|
|
314
|
+
# Without this an `optional` argument is generated as required, and
|
|
315
|
+
# the caller has no way to express "absent".
|
|
316
|
+
is_optional = bool(_OPTIONAL_RE.search(attrs))
|
|
317
|
+
|
|
318
|
+
for raw_name in decl.group("names").split(","):
|
|
319
|
+
raw_name = raw_name.strip()
|
|
320
|
+
if not raw_name:
|
|
321
|
+
continue
|
|
322
|
+
is_array = "(" in raw_name or "dimension" in attrs.lower()
|
|
323
|
+
var_name = raw_name.split("(")[0].strip()
|
|
324
|
+
declared[var_name.lower()] = Parameter(
|
|
325
|
+
name=var_name,
|
|
326
|
+
type=py_type,
|
|
327
|
+
is_array=is_array,
|
|
328
|
+
intent=intent,
|
|
329
|
+
is_optional=is_optional,
|
|
330
|
+
)
|
|
331
|
+
return declared
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _resolve_parameters(
|
|
335
|
+
match: re.Match,
|
|
336
|
+
declared: dict[str, Parameter],
|
|
337
|
+
unbindable: dict[str, str],
|
|
338
|
+
implicit_map: dict[str, str],
|
|
339
|
+
) -> list[Parameter]:
|
|
340
|
+
"""Type every dummy argument, or refuse the routine.
|
|
341
|
+
|
|
342
|
+
Three sources in decreasing authority: an explicit declaration, a
|
|
343
|
+
recognised-but-unbindable declaration (refuse), and IMPLICIT typing.
|
|
344
|
+
There is deliberately no fourth "assume float" step.
|
|
345
|
+
"""
|
|
346
|
+
raw_params = match.group("params") or ""
|
|
347
|
+
param_names = [p.strip() for p in raw_params.split(",") if p.strip()]
|
|
348
|
+
|
|
349
|
+
parameters: list[Parameter] = []
|
|
350
|
+
for param_name in param_names:
|
|
351
|
+
key = param_name.lower()
|
|
352
|
+
if key in declared:
|
|
353
|
+
parameters.append(declared[key])
|
|
354
|
+
continue
|
|
355
|
+
if key in unbindable:
|
|
356
|
+
raise _RoutineSkipped(
|
|
357
|
+
f"argument '{param_name}' is declared '{unbindable[key]}'; "
|
|
358
|
+
"f2py cannot pass a derived type."
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
base_type = fixed_form.implicit_type_of(param_name, implicit_map)
|
|
362
|
+
if base_type is None:
|
|
363
|
+
raise _RoutineSkipped(
|
|
364
|
+
f"argument '{param_name}' has no explicit declaration and the "
|
|
365
|
+
"source uses IMPLICIT NONE, so its type cannot be determined. "
|
|
366
|
+
"Declare it in the Fortran source."
|
|
367
|
+
)
|
|
368
|
+
parameters.append(Parameter(name=param_name, type=map_fortran_type(base_type)))
|
|
369
|
+
return parameters
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _prefix_return_type(prefix: str | None) -> str | None:
|
|
373
|
+
"""Fortran base type from a `real(8) function ...` style prefix, if any."""
|
|
374
|
+
if not prefix:
|
|
375
|
+
return None
|
|
376
|
+
if _PREFIX_DERIVED_RE.match(prefix):
|
|
377
|
+
raise _RoutineSkipped(
|
|
378
|
+
f"declared as returning '{prefix.strip()}'; "
|
|
379
|
+
"f2py cannot return a derived type."
|
|
380
|
+
)
|
|
381
|
+
type_match = _PREFIX_TYPE_RE.search(prefix)
|
|
382
|
+
return re.sub(r"\s+", " ", type_match.group(1).lower()) if type_match else None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# A `contains` statement inside a routine opens its INTERNAL procedures. Their
|
|
386
|
+
# declarations are not the outer routine's declarations, but `_find_block`'s
|
|
387
|
+
# body capture runs to the routine's end and so swallows them — and
|
|
388
|
+
# `_parse_declarations` keeps the last match for a name, so an internal
|
|
389
|
+
# procedure that happens to reuse an outer dummy argument's name silently
|
|
390
|
+
# retypes it. A helper declaring `integer :: a` inside a function whose own
|
|
391
|
+
# `a` is `real(8)` produced a binding typed `int`: it compiles, it runs, and
|
|
392
|
+
# it hands Fortran the wrong thing. Contamination is the same whether or not
|
|
393
|
+
# the internal procedure closes with a bare `end`.
|
|
394
|
+
#
|
|
395
|
+
# Everything a routine declares for itself precedes `contains`, so cutting
|
|
396
|
+
# there loses nothing and removes the whole class of leak.
|
|
397
|
+
_CONTAINS_RE = re.compile(r"^[ \t]*contains[ \t]*$", re.IGNORECASE | re.MULTILINE)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _own_declarations(body: str) -> str:
|
|
401
|
+
"""`body` up to its first `contains`, i.e. excluding internal procedures."""
|
|
402
|
+
match = _CONTAINS_RE.search(body)
|
|
403
|
+
return body[: match.start()] if match else body
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _free_form_doc(raw_source: str, name: str) -> str | None:
|
|
407
|
+
"""The comment header on a free-form routine, or None.
|
|
408
|
+
|
|
409
|
+
Located in the RAW file text, not the `normalize_free_form` output the
|
|
410
|
+
rest of this reader uses: normalisation blanks comments and joins
|
|
411
|
+
continuations, so by the time a routine is found the header is both gone
|
|
412
|
+
and at a different line number. The cleaning rules are shared with the
|
|
413
|
+
fparser2 backend (`fixed_form.doc_comment_at`) so both readers put
|
|
414
|
+
identical text in the IR — the differential harness compares whole
|
|
415
|
+
ModuleIRs and would fail on any disagreement.
|
|
416
|
+
"""
|
|
417
|
+
span = fixed_form.find_header_lines(raw_source, name, fixed=False)
|
|
418
|
+
if span is None:
|
|
419
|
+
return None
|
|
420
|
+
return fixed_form.doc_comment_at(raw_source, span[0], span[1], fixed=False)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _extract_function(
|
|
424
|
+
source: str, name: str, implicit_map: dict[str, str]
|
|
425
|
+
) -> tuple[FunctionDef, str | None] | None:
|
|
426
|
+
match = _find_block(source, "function", name)
|
|
427
|
+
if match is None:
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
body = _own_declarations(match.group("body"))
|
|
431
|
+
declared = _parse_declarations(body)
|
|
432
|
+
unbindable = _parse_derived_declarations(body)
|
|
433
|
+
|
|
434
|
+
parameters = _resolve_parameters(match, declared, unbindable, implicit_map)
|
|
435
|
+
|
|
436
|
+
result_name = (match.group("result") or name).lower()
|
|
437
|
+
result_param = declared.get(result_name)
|
|
438
|
+
if result_param is not None:
|
|
439
|
+
returns = result_param.type
|
|
440
|
+
elif result_name in unbindable:
|
|
441
|
+
raise _RoutineSkipped(
|
|
442
|
+
f"result variable '{result_name}' is declared "
|
|
443
|
+
f"'{unbindable[result_name]}'; f2py cannot return a derived type."
|
|
444
|
+
)
|
|
445
|
+
else:
|
|
446
|
+
base_type = _prefix_return_type(match.group("prefix")) or fixed_form.implicit_type_of(
|
|
447
|
+
result_name, implicit_map
|
|
448
|
+
)
|
|
449
|
+
if base_type is None:
|
|
450
|
+
raise _RoutineSkipped(
|
|
451
|
+
f"result variable '{result_name}' has no explicit declaration "
|
|
452
|
+
"and the source uses IMPLICIT NONE, so the return type cannot "
|
|
453
|
+
"be determined. Declare it in the Fortran source."
|
|
454
|
+
)
|
|
455
|
+
returns = map_fortran_type(base_type)
|
|
456
|
+
|
|
457
|
+
enclosing = _enclosing_fortran_module(source, match.start())
|
|
458
|
+
fn = FunctionDef(
|
|
459
|
+
name=name,
|
|
460
|
+
parameters=parameters,
|
|
461
|
+
returns=returns,
|
|
462
|
+
is_subroutine=False,
|
|
463
|
+
fortran_module=enclosing,
|
|
464
|
+
)
|
|
465
|
+
return fn, enclosing
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _extract_subroutine(
|
|
469
|
+
source: str, name: str, implicit_map: dict[str, str]
|
|
470
|
+
) -> tuple[FunctionDef, str | None] | None:
|
|
471
|
+
match = _find_block(source, "subroutine", name)
|
|
472
|
+
if match is None:
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
body = _own_declarations(match.group("body"))
|
|
476
|
+
declared = _parse_declarations(body)
|
|
477
|
+
unbindable = _parse_derived_declarations(body)
|
|
478
|
+
|
|
479
|
+
parameters = _resolve_parameters(match, declared, unbindable, implicit_map)
|
|
480
|
+
|
|
481
|
+
enclosing = _enclosing_fortran_module(source, match.start())
|
|
482
|
+
fn = FunctionDef(
|
|
483
|
+
name=name,
|
|
484
|
+
parameters=parameters,
|
|
485
|
+
returns="void",
|
|
486
|
+
is_subroutine=True,
|
|
487
|
+
fortran_module=enclosing,
|
|
488
|
+
)
|
|
489
|
+
return fn, enclosing
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _fixed_form_doc(expanded: str, name: str) -> str | None:
|
|
493
|
+
"""The comment header on a fixed-form routine, or None.
|
|
494
|
+
|
|
495
|
+
Located by scanning the raw text rather than the normalised statements —
|
|
496
|
+
see `fixed_form.find_header_lines` for why the normalised line numbers
|
|
497
|
+
cannot be used.
|
|
498
|
+
"""
|
|
499
|
+
span = fixed_form.find_header_lines(expanded, name)
|
|
500
|
+
if span is None:
|
|
501
|
+
return None
|
|
502
|
+
return fixed_form.doc_comment_at(expanded, span[0], span[1], fixed=True)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _parse_fixed_form_source(
|
|
506
|
+
path: Path, expose: ExposeConfig, include_paths: list[Path]
|
|
507
|
+
) -> ModuleIR:
|
|
508
|
+
"""FORTRAN 77 fixed-form path.
|
|
509
|
+
|
|
510
|
+
INCLUDEs are expanded first because that's usually where the IMPLICIT
|
|
511
|
+
statements and COMMON blocks live — without them, parameter types fall
|
|
512
|
+
back to guesses and the resulting bindings return wrong numbers rather
|
|
513
|
+
than failing. See preprocess.expand_includes for why f2py can't be
|
|
514
|
+
trusted to do this itself.
|
|
515
|
+
"""
|
|
516
|
+
expanded = expand_includes(path, include_paths)
|
|
517
|
+
normalized = fixed_form.normalize_fixed_form(expanded)
|
|
518
|
+
implicit_map = fixed_form.parse_implicit_map(normalized)
|
|
519
|
+
|
|
520
|
+
module = ModuleIR(name=path.stem, language="fortran", source_file=str(path))
|
|
521
|
+
# Fixed-form F77 predates modules; routines are always top level, so
|
|
522
|
+
# f2py exposes them directly with no nesting to bridge.
|
|
523
|
+
module.fortran_module = None
|
|
524
|
+
|
|
525
|
+
for name in expose.functions:
|
|
526
|
+
routine = fixed_form.find_routine(normalized, name)
|
|
527
|
+
if routine is None:
|
|
528
|
+
available = ", ".join(fixed_form.list_routine_names(normalized)[:15])
|
|
529
|
+
raise ValueError(
|
|
530
|
+
f"Could not find function or subroutine '{name}' in {path}. "
|
|
531
|
+
f"Routines found: {available or '(none)'}"
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
# F77 declares no intent, so it is recovered from the body: an
|
|
535
|
+
# argument that is assigned to is an output. Without this every
|
|
536
|
+
# scalar binds as intent(in) and its result is silently discarded —
|
|
537
|
+
# `STEP(DTIN, DTOUT, ICONV)` would return None.
|
|
538
|
+
intents = fixed_form.infer_intents(routine, normalized)
|
|
539
|
+
|
|
540
|
+
parameters = []
|
|
541
|
+
for param_name in routine["params"]:
|
|
542
|
+
key = param_name.lower()
|
|
543
|
+
base_type = routine["declared_types"].get(key) or fixed_form.implicit_type_of(
|
|
544
|
+
param_name, implicit_map
|
|
545
|
+
)
|
|
546
|
+
if base_type is None:
|
|
547
|
+
raise ValueError(
|
|
548
|
+
f"{path.name}: parameter '{param_name}' of '{name}' has no explicit "
|
|
549
|
+
"declaration and the source uses IMPLICIT NONE, so its type cannot "
|
|
550
|
+
"be determined. Declare it in the Fortran source."
|
|
551
|
+
)
|
|
552
|
+
py_type = map_fortran_type(base_type)
|
|
553
|
+
intent = intents.get(key, "in")
|
|
554
|
+
|
|
555
|
+
# Same rule, same message, same recorded spelling as the fparser2
|
|
556
|
+
# backend — the parity harness holds both to byte-identical IR. A
|
|
557
|
+
# CHARACTER output binds exactly when its length is fixed in the
|
|
558
|
+
# declaration; an assumed-length output BUILDS and then silently
|
|
559
|
+
# returns b'' (measured with numpy 2.5's f2py), so it is demoted
|
|
560
|
+
# with the reason.
|
|
561
|
+
char_spelling = (
|
|
562
|
+
routine.get("char_lengths", {}).get(key)
|
|
563
|
+
if py_type == "str"
|
|
564
|
+
else None
|
|
565
|
+
)
|
|
566
|
+
if py_type == "str" and intent != "in":
|
|
567
|
+
if char_spelling is None or not fixed_form.CHAR_FIXED_LEN_RE.match(
|
|
568
|
+
char_spelling
|
|
569
|
+
):
|
|
570
|
+
module.skipped.append(
|
|
571
|
+
SkippedSymbol(
|
|
572
|
+
f"{name}({param_name})",
|
|
573
|
+
f"'{param_name}' is assigned in the body but is "
|
|
574
|
+
f"declared '{char_spelling or 'implicitly'}': f2py "
|
|
575
|
+
"can only return a CHARACTER whose length is fixed "
|
|
576
|
+
"in the declaration. An assumed-length output "
|
|
577
|
+
"builds and then silently returns an empty string "
|
|
578
|
+
"— measured, not assumed. Give it a length "
|
|
579
|
+
"(CHARACTER*80) and it becomes an output.",
|
|
580
|
+
)
|
|
581
|
+
)
|
|
582
|
+
intent = "in"
|
|
583
|
+
|
|
584
|
+
parameters.append(
|
|
585
|
+
Parameter(
|
|
586
|
+
name=param_name,
|
|
587
|
+
type=py_type,
|
|
588
|
+
is_array=key in routine["arrays"],
|
|
589
|
+
native_type=char_spelling,
|
|
590
|
+
intent=intent,
|
|
591
|
+
)
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
is_subroutine = routine["kind"] == "subroutine"
|
|
595
|
+
if is_subroutine:
|
|
596
|
+
returns = "void"
|
|
597
|
+
else:
|
|
598
|
+
declared = routine["declared_types"].get(name.lower())
|
|
599
|
+
prefix = routine["prefix"]
|
|
600
|
+
base = declared or (prefix.lower() if prefix else None) or fixed_form.implicit_type_of(
|
|
601
|
+
name, implicit_map
|
|
602
|
+
)
|
|
603
|
+
returns = map_fortran_type(base) if base else "float"
|
|
604
|
+
|
|
605
|
+
module.functions.append(
|
|
606
|
+
FunctionDef(
|
|
607
|
+
name=name,
|
|
608
|
+
parameters=parameters,
|
|
609
|
+
returns=returns,
|
|
610
|
+
is_subroutine=is_subroutine,
|
|
611
|
+
# Fixed-form F77 predates modules: always top level.
|
|
612
|
+
fortran_module=None,
|
|
613
|
+
# Read off `expanded` — the raw INCLUDE-expanded text, not
|
|
614
|
+
# `normalized`, whose line numbers no longer point at the
|
|
615
|
+
# comments because normalisation strips them.
|
|
616
|
+
doc=_fixed_form_doc(expanded, name),
|
|
617
|
+
)
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
return module
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def parse_source(
|
|
624
|
+
path: Path, expose: ExposeConfig, include_paths: list[Path] | None = None
|
|
625
|
+
) -> ModuleIR:
|
|
626
|
+
if not expose.functions:
|
|
627
|
+
raise ValueError(
|
|
628
|
+
"Fortran sources require explicit `expose.functions:` entries in nativegate.yaml "
|
|
629
|
+
"(no implicit expose-everything fallback — large legacy files are parsed "
|
|
630
|
+
"one requested routine at a time)."
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
if is_fixed_form(path):
|
|
634
|
+
return _parse_fixed_form_source(path, expose, include_paths or [])
|
|
635
|
+
|
|
636
|
+
# Continuations are joined before anything looks for a routine: every
|
|
637
|
+
# pattern below treats one line as one statement, and `_find_block`'s
|
|
638
|
+
# `[^)]*` argument capture spans newlines, so a wrapped argument list
|
|
639
|
+
# otherwise produces a parameter named "&".
|
|
640
|
+
raw_source = path.read_text()
|
|
641
|
+
source = normalize_free_form(raw_source)
|
|
642
|
+
module = ModuleIR(name=path.stem, language="fortran", source_file=str(path))
|
|
643
|
+
|
|
644
|
+
# IMPLICIT typing is dialect-independent, so the fixed-form implementation
|
|
645
|
+
# is reused rather than duplicated. Without it an undeclared argument in a
|
|
646
|
+
# `.f90` that omits `implicit none` — legal, and common in transitional
|
|
647
|
+
# 1990s code — silently bound as float; with it, `implicit none` instead
|
|
648
|
+
# makes the routine a reported skip.
|
|
649
|
+
implicit_map = fixed_form.parse_implicit_map(source)
|
|
650
|
+
|
|
651
|
+
fortran_module_name: str | None = "__unset__"
|
|
652
|
+
for name in expose.functions:
|
|
653
|
+
try:
|
|
654
|
+
found = _extract_function(source, name, implicit_map) or _extract_subroutine(
|
|
655
|
+
source, name, implicit_map
|
|
656
|
+
)
|
|
657
|
+
except _RoutineSkipped as skipped:
|
|
658
|
+
# Recognised but not bindable: reported through the channel that
|
|
659
|
+
# exists for exactly this, and omitted from module.functions so
|
|
660
|
+
# nothing downstream wraps a type f2py cannot carry.
|
|
661
|
+
module.skipped.append(SkippedSymbol(name, skipped.reason))
|
|
662
|
+
continue
|
|
663
|
+
|
|
664
|
+
if found is None:
|
|
665
|
+
raise ValueError(
|
|
666
|
+
f"Could not find function or subroutine '{name}' in {path}. "
|
|
667
|
+
"Check the name in nativegate.yaml matches the Fortran source exactly."
|
|
668
|
+
)
|
|
669
|
+
fn, enclosing_module = found
|
|
670
|
+
# Set here rather than inside the extractors: the doc is read off the
|
|
671
|
+
# raw file, which those work without.
|
|
672
|
+
fn.doc = _free_form_doc(raw_source, name)
|
|
673
|
+
module.functions.append(fn)
|
|
674
|
+
|
|
675
|
+
if fortran_module_name == "__unset__":
|
|
676
|
+
fortran_module_name = enclosing_module
|
|
677
|
+
elif fortran_module_name != enclosing_module:
|
|
678
|
+
raise ValueError(
|
|
679
|
+
f"'{name}' is defined in Fortran module '{enclosing_module}', but earlier "
|
|
680
|
+
f"exposed routines came from '{fortran_module_name}'. nativegate generates one "
|
|
681
|
+
"Python package per source file and can't mix routines from different "
|
|
682
|
+
"Fortran modules — split them into separate nativegate.yaml services."
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
module.fortran_module = None if fortran_module_name == "__unset__" else fortran_module_name
|
|
686
|
+
return module
|