nativegate 0.1.7__tar.gz → 0.1.9__tar.gz

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.
Files changed (111) hide show
  1. {nativegate-0.1.7 → nativegate-0.1.9}/PKG-INFO +8 -1
  2. {nativegate-0.1.7 → nativegate-0.1.9}/README.md +7 -0
  3. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/parsers/fortran.py +42 -0
  4. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/parsers/fortran_fparser.py +59 -6
  5. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/parsers/fortran_regex.py +75 -5
  6. nativegate-0.1.9/nativegate/parsers/hollerith.py +93 -0
  7. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate.egg-info/PKG-INFO +8 -1
  8. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate.egg-info/SOURCES.txt +3 -0
  9. {nativegate-0.1.7 → nativegate-0.1.9}/pyproject.toml +1 -1
  10. nativegate-0.1.9/tests/test_external_callbacks.py +79 -0
  11. nativegate-0.1.9/tests/test_hollerith.py +84 -0
  12. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/__init__.py +0 -0
  13. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/__main__.py +0 -0
  14. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/buildinfo.py +0 -0
  15. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/cli.py +0 -0
  16. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/config.py +0 -0
  17. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/declared_invariants.py +0 -0
  18. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/discovery.py +0 -0
  19. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/driverbuild.py +0 -0
  20. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/drivers/__init__.py +0 -0
  21. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/drivers/cpp.py +0 -0
  22. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/drivers/fortran.py +0 -0
  23. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/generators/__init__.py +0 -0
  24. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/generators/cmake_gen.py +0 -0
  25. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/generators/docker_gen.py +0 -0
  26. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/generators/error_gen.py +0 -0
  27. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/generators/f2py_gen.py +0 -0
  28. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/generators/gateway_gen.py +0 -0
  29. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/generators/golden_gen.py +0 -0
  30. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/generators/k8s_gen.py +0 -0
  31. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/generators/mcp_gen.py +0 -0
  32. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/generators/middleware_gen.py +0 -0
  33. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/generators/pybind_gen.py +0 -0
  34. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/generators/pyproject_gen.py +0 -0
  35. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/generators/python_pkg_gen.py +0 -0
  36. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/generators/test_gen.py +0 -0
  37. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/golden.py +0 -0
  38. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/invariants.py +0 -0
  39. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/ir.py +0 -0
  40. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/lattice.py +0 -0
  41. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/locking.py +0 -0
  42. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/oracle.py +0 -0
  43. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/parsers/__init__.py +0 -0
  44. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/parsers/cpp.py +0 -0
  45. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/parsers/cpp_ast.py +0 -0
  46. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/parsers/cpp_regex.py +0 -0
  47. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/parsers/dialect.py +0 -0
  48. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/parsers/fixed_form.py +0 -0
  49. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/preprocess.py +0 -0
  50. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/structural_invariants.py +0 -0
  51. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/suggest.py +0 -0
  52. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/templates/__init__.py +0 -0
  53. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/templates/golden_test_template.py +0 -0
  54. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate/wire.py +0 -0
  55. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate.egg-info/dependency_links.txt +0 -0
  56. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate.egg-info/entry_points.txt +0 -0
  57. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate.egg-info/requires.txt +0 -0
  58. {nativegate-0.1.7 → nativegate-0.1.9}/nativegate.egg-info/top_level.txt +0 -0
  59. {nativegate-0.1.7 → nativegate-0.1.9}/setup.cfg +0 -0
  60. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_buildinfo.py +0 -0
  61. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_cli_serve_verify.py +0 -0
  62. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_cli_suggest.py +0 -0
  63. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_cli_verify_aggregate.py +0 -0
  64. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_cmake_gen.py +0 -0
  65. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_cmake_missing_input.py +0 -0
  66. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_config_unknown_keys.py +0 -0
  67. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_cpp_ast.py +0 -0
  68. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_cpp_driver.py +0 -0
  69. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_cpp_pipeline.py +0 -0
  70. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_declared_invariants.py +0 -0
  71. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_derived_types.py +0 -0
  72. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_dialect.py +0 -0
  73. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_docker_gen.py +0 -0
  74. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_driverbuild.py +0 -0
  75. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_endpoint_docstrings.py +0 -0
  76. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_fixed_form.py +0 -0
  77. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_fortran_doc_comments.py +0 -0
  78. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_fortran_driver.py +0 -0
  79. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_fortran_fparser.py +0 -0
  80. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_fortran_libraries.py +0 -0
  81. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_fortran_pipeline.py +0 -0
  82. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_gateway_gen.py +0 -0
  83. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_generated_python_is_valid.py +0 -0
  84. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_golden.py +0 -0
  85. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_inspect_dialect.py +0 -0
  86. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_invariants.py +0 -0
  87. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_ir_contract.py +0 -0
  88. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_k8s_gen.py +0 -0
  89. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_lattice.py +0 -0
  90. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_legacy_cpp.py +0 -0
  91. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_legacy_fortran.py +0 -0
  92. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_locking.py +0 -0
  93. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_mcp_gen.py +0 -0
  94. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_middleware_gen.py +0 -0
  95. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_multifile_cpp.py +0 -0
  96. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_native_oracle.py +0 -0
  97. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_native_state_contract.py +0 -0
  98. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_oracle.py +0 -0
  99. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_preprocess_and_config.py +0 -0
  100. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_preprocessed_fortran.py +0 -0
  101. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_pybind_gen.py +0 -0
  102. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_pyproject_gen.py +0 -0
  103. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_quickstart.py +0 -0
  104. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_scalar_ref.py +0 -0
  105. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_service_gen.py +0 -0
  106. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_service_name_validation.py +0 -0
  107. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_shared_libraries.py +0 -0
  108. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_structural_invariants.py +0 -0
  109. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_suggest.py +0 -0
  110. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_verification_config.py +0 -0
  111. {nativegate-0.1.7 → nativegate-0.1.9}/tests/test_wire.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nativegate
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: Make legacy C++/C/Fortran AI-ready and microservice-ready: expose native code as an MCP server, REST API and container
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -552,3 +552,10 @@ mapping. Each refusal is printed at `generate` time with its reason, and the
552
552
  generated service publishes them at `GET /_unexposed` so they stay visible to
553
553
  whoever is calling the API. An empty mapping there means this build refused
554
554
  nothing; a 404 means the service predates the route and cannot tell you.
555
+
556
+ ## Community
557
+
558
+ Contributing: see the repository's [CONTRIBUTING.md](../../CONTRIBUTING.md).
559
+ Participation in this project's spaces is covered by the
560
+ [Code of Conduct](../../CODE_OF_CONDUCT.md); reports go to
561
+ sr.rabiu@gmail.com.
@@ -519,3 +519,10 @@ mapping. Each refusal is printed at `generate` time with its reason, and the
519
519
  generated service publishes them at `GET /_unexposed` so they stay visible to
520
520
  whoever is calling the API. An empty mapping there means this build refused
521
521
  nothing; a 404 means the service predates the route and cannot tell you.
522
+
523
+ ## Community
524
+
525
+ Contributing: see the repository's [CONTRIBUTING.md](../../CONTRIBUTING.md).
526
+ Participation in this project's spaces is covered by the
527
+ [Code of Conduct](../../CODE_OF_CONDUCT.md); reports go to
528
+ sr.rabiu@gmail.com.
@@ -65,10 +65,12 @@ from __future__ import annotations
65
65
 
66
66
  import os
67
67
  from pathlib import Path
68
+ import tempfile
68
69
 
69
70
  from ..config import ExposeConfig
70
71
  from ..ir import ModuleIR
71
72
  from . import fortran_fparser, fortran_regex
73
+ from . import hollerith
72
74
 
73
75
  # Re-exported so callers that reach for the free-form continuation joiner (the
74
76
  # CLI's source-rewriting paths, and a good number of tests) keep working after
@@ -137,6 +139,31 @@ def backend_description(backend: str | None = None) -> str:
137
139
  )
138
140
 
139
141
 
142
+ def _hollerith_resolved(path: Path) -> Path | None:
143
+ """A temp copy of `path` with Hollerith constants resolved, or None.
144
+
145
+ fparser2 refuses Hollerith constants gfortran compiles with a warning —
146
+ the one measured counterexample to the parity rule that made fparser2
147
+ the default (DEFECTS D15, netlib quadpack). The file gfortran accepts
148
+ is the file that should parse, so the front door resolves the constants
149
+ into their standard quoted spelling and reads that copy instead. The
150
+ copy is attributed back to the original path, so source-file references
151
+ and doc-comment line spans keep pointing at the real file.
152
+ """
153
+ raw = path.read_text()
154
+ if not hollerith.is_hollerith(raw):
155
+ return None
156
+ resolved, _ = hollerith.resolve_hollerith(raw)
157
+ handle, name = tempfile.mkstemp(suffix=path.suffix)
158
+ Path(name).write_text(resolved)
159
+ # mkstemp exposes an int fd; close it — Path.write_text already used
160
+ # a second open, and the raw descriptor must not leak.
161
+ import os
162
+
163
+ os.close(handle)
164
+ return Path(name)
165
+
166
+
140
167
  def parse_source(
141
168
  path: Path,
142
169
  expose: ExposeConfig,
@@ -146,6 +173,15 @@ def parse_source(
146
173
  ) -> ModuleIR:
147
174
  """Parse one Fortran source into a ModuleIR using the selected backend."""
148
175
  if resolve_backend(backend) == "fparser2":
176
+ resolved = _hollerith_resolved(path)
177
+ if resolved is not None:
178
+ module = fortran_fparser.parse_source(resolved, expose, include_paths)
179
+ # The temp copy must not leak into user-visible IR: same name,
180
+ # same source file, only the text differs (D15).
181
+ module.name = path.stem
182
+ module.source_file = str(path)
183
+ resolved.unlink(missing_ok=True)
184
+ return module
149
185
  return fortran_fparser.parse_source(path, expose, include_paths)
150
186
  return fortran_regex.parse_source(path, expose, include_paths)
151
187
 
@@ -153,5 +189,11 @@ def parse_source(
153
189
  def list_routine_names(path: Path, *, backend: str | None = None) -> list[str]:
154
190
  """Every function/subroutine name in the file, in source order."""
155
191
  if resolve_backend(backend) == "fparser2":
192
+ resolved = _hollerith_resolved(path)
193
+ if resolved is not None:
194
+ try:
195
+ return fortran_fparser.list_routine_names(resolved)
196
+ finally:
197
+ resolved.unlink(missing_ok=True)
156
198
  return fortran_fparser.list_routine_names(path)
157
199
  return fortran_regex.list_routine_names(path)
@@ -454,6 +454,13 @@ class _Declarations:
454
454
  derived_intents: dict[str, str] = field(default_factory=dict)
455
455
  # Names given a shape by a standalone DIMENSION statement.
456
456
  dimensioned: set[str] = field(default_factory=set)
457
+ # Names declared EXTERNAL — Fortran procedure dummies (callbacks). f2py
458
+ # binds these only with per-argument callback directives, which nativegate
459
+ # does not generate; typing one by its IMPLICIT fallback (`real`, because
460
+ # 'f' is not in the I-N band) produced a binding that builds, imports,
461
+ # passes its smoke test and hands Fortran garbage at call time. Recorded
462
+ # first contact: netlib quadpack's QNG/QAGSE (DEFECTS D14).
463
+ external: set[str] = field(default_factory=set)
457
464
 
458
465
 
459
466
  def _parse_declarations(spec_part) -> _Declarations:
@@ -468,6 +475,17 @@ def _parse_declarations(spec_part) -> _Declarations:
468
475
  for item in stmt.items[0]:
469
476
  result.dimensioned.add(_name_of(item[0]).lower())
470
477
 
478
+ # EXTERNAL names are Fortran procedure dummies — callbacks. See the
479
+ # `external` field note above for why typing them is a silently wrong
480
+ # answer, and DEFECTS D14 for the netlib quadpack contact. External_Name
481
+ # nodes never appear as standalone tree entries, so read the names off
482
+ # External_Stmt.items: item 0 is the keyword, the last is the name list
483
+ # (the arity lines up across the fixed- and free-form grammars).
484
+ for stmt in _walk(spec_part, _F.External_Stmt):
485
+ name_list = stmt.items[-1]
486
+ for item in getattr(name_list, "items", ()):
487
+ result.external.add(_name_of(item).lower())
488
+
471
489
  for decl in _walk(spec_part, _F.Type_Declaration_Stmt):
472
490
  type_spec = decl.items[0]
473
491
  if _is_derived(type_spec):
@@ -698,17 +716,30 @@ def _resolve_parameters(
698
716
  declared: dict[str, Parameter],
699
717
  unbindable: dict[str, str],
700
718
  implicit_map: dict[str, str],
719
+ external_names: set[str] = frozenset(),
701
720
  ) -> list[Parameter]:
702
721
  """Type every dummy argument, or refuse the routine.
703
722
 
704
- Three sources in decreasing authority: an explicit declaration, a
705
- recognised-but-unbindable declaration (refuse), and IMPLICIT typing. There
706
- is deliberately no fourth "assume float" step — the reason the reasons
707
- below exist at all.
723
+ Four sources in decreasing authority: EXTERNAL declarations (refuse the
724
+ routine — callbacks have their own semantic and no generated binding),
725
+ an explicit declaration, a recognised-but-unbindable declaration
726
+ (refuse), and IMPLICIT typing. There is deliberately no fifth "assume
727
+ float" step — the reason the reasons below exist at all.
708
728
  """
709
729
  parameters: list[Parameter] = []
710
730
  for param_name in param_names:
711
731
  key = param_name.lower()
732
+ # EXTERNAL check ahead of every type lookup: a name in the external
733
+ # list is a procedure dummy, whatever it implicitly types as. 'f' is
734
+ # not in the I-N band, so the fallback it used to hit was real/float.
735
+ if key in external_names:
736
+ raise _RoutineSkipped(
737
+ f"argument '{param_name}' is declared EXTERNAL (a Fortran "
738
+ "procedure/callback). f2py binds callbacks only through per-"
739
+ "argument f2py directives, which the generator does not emit "
740
+ "yet — this routine is skipped as a whole rather than bound "
741
+ "with the callback mis-typed as a scalar."
742
+ )
712
743
  if key in declared:
713
744
  parameters.append(declared[key])
714
745
  continue
@@ -824,11 +855,11 @@ def _free_form_routine(
824
855
  parameters.extend(flat_by_param[param])
825
856
  else:
826
857
  parameters.extend(
827
- _resolve_parameters([param], declared, unbindable, implicit_map)
858
+ _resolve_parameters([param], declared, unbindable, implicit_map, decls.external)
828
859
  )
829
860
  else:
830
861
  parameters = _resolve_parameters(
831
- param_names, declared, unbindable, implicit_map
862
+ param_names, declared, unbindable, implicit_map, decls.external
832
863
  )
833
864
 
834
865
  if is_subroutine:
@@ -987,8 +1018,26 @@ def _parse_fixed_form(path: Path, expose: ExposeConfig, include_paths: list[Path
987
1018
  )
988
1019
 
989
1020
  parameters = []
1021
+ external_refused = False
990
1022
  for param_name in param_names:
991
1023
  key = param_name.lower()
1024
+ # Ahead of every type lookup: an EXTERNAL name is a procedure
1025
+ # dummy whatever it implicitly types as (DEFECTS D14 — netlib
1026
+ # quadpack QNG/QAGSE).
1027
+ if key in decls.external:
1028
+ module.skipped.append(
1029
+ SkippedSymbol(
1030
+ name,
1031
+ f"argument '{param_name}' is declared EXTERNAL (a "
1032
+ "Fortran procedure/callback). f2py binds callbacks "
1033
+ "only through per-argument f2py directives, which "
1034
+ "the generator does not emit yet — skipped as a "
1035
+ "whole rather than bound with the callback "
1036
+ "mis-typed as a scalar.",
1037
+ )
1038
+ )
1039
+ external_refused = True
1040
+ break
992
1041
  declaration = decls.declared.get(key)
993
1042
  if declaration is not None:
994
1043
  py_type = declaration.type
@@ -1068,6 +1117,10 @@ def _parse_fixed_form(path: Path, expose: ExposeConfig, include_paths: list[Path
1068
1117
  )
1069
1118
  returns = map_fortran_type(base) if base else "float"
1070
1119
 
1120
+ if external_refused:
1121
+ # Recorded in module.skipped; nothing downstream may wrap it.
1122
+ continue
1123
+
1071
1124
  module.functions.append(
1072
1125
  FunctionDef(
1073
1126
  name=name,
@@ -299,6 +299,29 @@ def _parse_derived_declarations(body: str) -> dict[str, str]:
299
299
  return unbindable
300
300
 
301
301
 
302
+ # EXTERNAL declarations name Fortran procedure dummies — callbacks. Stands
303
+ # in the fixed-form `external f` shape AND the free-form `external :: f, g`
304
+ # one. Collected so the routine that takes one is skipped with a reason
305
+ # (both readers, identical text): typing it through the IMPLICIT fallback —
306
+ # 'f' is real, since 'f' sits outside the I-N integer band — produced a
307
+ # binding that builds, imports, smokes green and hands Fortran garbage at
308
+ # call time. First contact: netlib quadpack QNG/QAGSE (DEFECTS D14).
309
+ _EXTERNAL_RE = re.compile(
310
+ r"^[ \t]*external[ \t]*(?::[ \t]*)?([a-z][a-z0-9_, \t]*)$",
311
+ re.IGNORECASE | re.MULTILINE,
312
+ )
313
+
314
+
315
+ def _external_declared_names(body: str) -> set[str]:
316
+ out: set[str] = set()
317
+ for match in _EXTERNAL_RE.finditer(body):
318
+ for raw in match.group(1).split(","):
319
+ name = raw.strip().lower()
320
+ if name:
321
+ out.add(name)
322
+ return out
323
+
324
+
302
325
  def _parse_declarations(body: str) -> dict[str, Parameter]:
303
326
  """Map declared variable name -> Parameter, for every `type, attrs :: names` line in body."""
304
327
  declared: dict[str, Parameter] = {}
@@ -336,19 +359,34 @@ def _resolve_parameters(
336
359
  declared: dict[str, Parameter],
337
360
  unbindable: dict[str, str],
338
361
  implicit_map: dict[str, str],
362
+ external_names=None,
339
363
  ) -> list[Parameter]:
340
364
  """Type every dummy argument, or refuse the routine.
341
365
 
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.
366
+ Four sources in decreasing authority: EXTERNAL declarations (refuse the
367
+ routine — a callback typed via the IMPLICIT fallback is a buildable,
368
+ importable, silently wrong binding; see DEFECTS D14 / netlib quadpack),
369
+ an explicit declaration, a recognised-but-unbindable declaration
370
+ (refuse), and IMPLICIT typing. There is deliberately no fifth "assume
371
+ float" step.
345
372
  """
373
+ external_names = external_names if external_names is not None else set()
346
374
  raw_params = match.group("params") or ""
347
375
  param_names = [p.strip() for p in raw_params.split(",") if p.strip()]
348
376
 
349
377
  parameters: list[Parameter] = []
350
378
  for param_name in param_names:
351
379
  key = param_name.lower()
380
+ # Ahead of every type lookup: an EXTERNAL name is a procedure dummy
381
+ # whatever it implicitly types as — see the note on _EXTERNAL_RE.
382
+ if key in (external_names or ()):
383
+ raise _RoutineSkipped(
384
+ f"argument '{param_name}' is declared EXTERNAL (a Fortran "
385
+ "procedure/callback). f2py binds callbacks only through per-"
386
+ "argument f2py directives, which the generator does not emit "
387
+ "yet — this routine is skipped as a whole rather than bound "
388
+ "with the callback mis-typed as a scalar."
389
+ )
352
390
  if key in declared:
353
391
  parameters.append(declared[key])
354
392
  continue
@@ -431,7 +469,9 @@ def _extract_function(
431
469
  declared = _parse_declarations(body)
432
470
  unbindable = _parse_derived_declarations(body)
433
471
 
434
- parameters = _resolve_parameters(match, declared, unbindable, implicit_map)
472
+ parameters = _resolve_parameters(
473
+ match, declared, unbindable, implicit_map, _external_declared_names(body)
474
+ )
435
475
 
436
476
  result_name = (match.group("result") or name).lower()
437
477
  result_param = declared.get(result_name)
@@ -476,7 +516,9 @@ def _extract_subroutine(
476
516
  declared = _parse_declarations(body)
477
517
  unbindable = _parse_derived_declarations(body)
478
518
 
479
- parameters = _resolve_parameters(match, declared, unbindable, implicit_map)
519
+ parameters = _resolve_parameters(
520
+ match, declared, unbindable, implicit_map, _external_declared_names(body)
521
+ )
480
522
 
481
523
  enclosing = _enclosing_fortran_module(source, match.start())
482
524
  fn = FunctionDef(
@@ -537,9 +579,32 @@ def _parse_fixed_form_source(
537
579
  # `STEP(DTIN, DTOUT, ICONV)` would return None.
538
580
  intents = fixed_form.infer_intents(routine, normalized)
539
581
 
582
+ # The fixed-form half has its own declaration scan (`find_routine` +
583
+ # `declared_types`), which never consulted the free-form extractors
584
+ # — so an `external f` (a Fortran procedure dummy) typed straight
585
+ # through the IMPLICIT fallback to `real`/float and produced a
586
+ # binding that builds, imports, passes its smoke test and hands
587
+ # Fortran garbage at call time. Found on netlib quadpack QNG/QAGSE
588
+ # (DEFECTS D14); refusal is the interpretation both backends share.
589
+ external_names = _external_declared_names(routine["body"])
540
590
  parameters = []
541
591
  for param_name in routine["params"]:
542
592
  key = param_name.lower()
593
+ # Ahead of every type lookup: an EXTERNAL name is a procedure
594
+ # dummy whatever it implicitly types as — see the note above.
595
+ if key in external_names:
596
+ module.skipped.append(
597
+ SkippedSymbol(
598
+ name,
599
+ f"argument '{param_name}' is declared EXTERNAL (a "
600
+ "Fortran procedure/callback). f2py binds callbacks "
601
+ "only through per-argument f2py directives, which "
602
+ "the generator does not emit yet — skipped as a "
603
+ "whole rather than bound with the callback "
604
+ "mis-typed as a scalar.",
605
+ )
606
+ )
607
+ break
543
608
  base_type = routine["declared_types"].get(key) or fixed_form.implicit_type_of(
544
609
  param_name, implicit_map
545
610
  )
@@ -602,6 +667,11 @@ def _parse_fixed_form_source(
602
667
  )
603
668
  returns = map_fortran_type(base) if base else "float"
604
669
 
670
+ # A routine whose whole binding was refused (external callback) has
671
+ # been recorded in module.skipped; nothing downstream may wrap it.
672
+ if any(s.name == name for s in module.skipped):
673
+ continue
674
+
605
675
  module.functions.append(
606
676
  FunctionDef(
607
677
  name=name,
@@ -0,0 +1,93 @@
1
+ """Hollerith constants in fixed-form Fortran — rewrite to CHARACTER literals.
2
+
3
+ The netlib quadpack contact (DEFECTS D15) is the canonical case: 1970s-era
4
+ sources pass error text as Hollerith constants,
5
+
6
+ call xerror(26habnormal return from qng ,26,ier,0)
7
+
8
+ gfortran compiles that (legacy extension, warning only), but fparser2
9
+ refuses the whole file — the first measured counterexample to the
10
+ "fparser2 is never stricter than gfortran" rule that justifies fparser2
11
+ being the default front end. Since nativegate must compile with gfortran
12
+ anyway, the file gfortran accepts is exactly the file that should reach
13
+ the parser; the quoted CHARACTER constant is the standard F77 spelling of
14
+ the same argument, which f2py's own reader accepts too.
15
+
16
+ Deliberately conservative:
17
+
18
+ * Fixed-form text only; F90+ sources do not carry Hollerith.
19
+ * One constant per physical line: an `N*H` whose count runs past the line
20
+ end (it continues onto the next) is left untouched — rewriting it would
21
+ move every following line number and shift the doc-comment spans both
22
+ backends position by line.
23
+ * The rewritten line must fit the punch-card margin (72 columns). One
24
+ that cannot is left alone. What survives un-resolved is the caller's
25
+ decision (see resolver_hollerith in parsers/fortran.py).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+
32
+ _HOLLERITH_START_RE = re.compile(r"(?<!\w)(\d+)\s*[hH]", re.ASCII)
33
+
34
+ _FIXED_FORM_LINE_LIMIT = 72
35
+
36
+
37
+ def is_hollerith(text: str) -> bool:
38
+ return bool(_HOLLERITH_START_RE.search(text))
39
+
40
+
41
+ def resolve_hollerith(text: str) -> tuple[str, int]:
42
+ """Replace resolvable Hollerith constants with quoted literals, in line.
43
+
44
+ Returns (text, resolved). Line count is always preserved, so line-based
45
+ spans (doc comments, fparser2 item spans) stay put — the same invariant
46
+ the dialect pass keeps.
47
+ """
48
+ if not is_hollerith(text):
49
+ return text, 0
50
+
51
+ out_lines = []
52
+ resolved_total = 0
53
+ for line in text.splitlines(keepends=True):
54
+ resolved_line, n = _resolve_line(line)
55
+ out_lines.append(resolved_line)
56
+ resolved_total += n
57
+ return "".join(out_lines), resolved_total
58
+
59
+
60
+ def _resolve_line(line: str) -> tuple[str, int]:
61
+ """Substitute every fitting Hollerith constant on one physical line."""
62
+ end_of_line = len(line.rstrip("\r\n"))
63
+ eol = line[end_of_line:]
64
+ body = line[:end_of_line]
65
+
66
+ resolved = 0
67
+ position = 0
68
+ while position <= len(body):
69
+ match = _HOLLERITH_START_RE.search(body, position)
70
+ if match is None:
71
+ break
72
+ count = int(match.group(1))
73
+ start = match.end()
74
+ if start + count > len(body):
75
+ # the constant counts past this physical line — it continues on
76
+ # the next; rewriting it would move following line numbers
77
+ break
78
+ literal = body[start : start + count]
79
+ quoted = _quote(literal)
80
+ new_line = f"{body[: match.start()]}{quoted}{body[start + count :]}"
81
+ if len(new_line) > _FIXED_FORM_LINE_LIMIT:
82
+ # never widen the punch-card margin; leave this one alone
83
+ break
84
+ body = new_line
85
+ resolved += 1
86
+ position = match.start() + len(quoted)
87
+ return body + eol, resolved
88
+
89
+
90
+ def _quote(literal: str) -> str:
91
+ if "'" in literal:
92
+ return '"' + literal.replace('"', '""') + '"'
93
+ return "'" + literal + "'"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nativegate
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: Make legacy C++/C/Fortran AI-ready and microservice-ready: expose native code as an MCP server, REST API and container
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -552,3 +552,10 @@ mapping. Each refusal is printed at `generate` time with its reason, and the
552
552
  generated service publishes them at `GET /_unexposed` so they stay visible to
553
553
  whoever is calling the API. An empty mapping there means this build refused
554
554
  nothing; a 404 means the service predates the route and cannot tell you.
555
+
556
+ ## Community
557
+
558
+ Contributing: see the repository's [CONTRIBUTING.md](../../CONTRIBUTING.md).
559
+ Participation in this project's spaces is covered by the
560
+ [Code of Conduct](../../CODE_OF_CONDUCT.md); reports go to
561
+ sr.rabiu@gmail.com.
@@ -50,6 +50,7 @@ nativegate/parsers/fixed_form.py
50
50
  nativegate/parsers/fortran.py
51
51
  nativegate/parsers/fortran_fparser.py
52
52
  nativegate/parsers/fortran_regex.py
53
+ nativegate/parsers/hollerith.py
53
54
  nativegate/templates/__init__.py
54
55
  nativegate/templates/golden_test_template.py
55
56
  tests/test_buildinfo.py
@@ -68,6 +69,7 @@ tests/test_dialect.py
68
69
  tests/test_docker_gen.py
69
70
  tests/test_driverbuild.py
70
71
  tests/test_endpoint_docstrings.py
72
+ tests/test_external_callbacks.py
71
73
  tests/test_fixed_form.py
72
74
  tests/test_fortran_doc_comments.py
73
75
  tests/test_fortran_driver.py
@@ -77,6 +79,7 @@ tests/test_fortran_pipeline.py
77
79
  tests/test_gateway_gen.py
78
80
  tests/test_generated_python_is_valid.py
79
81
  tests/test_golden.py
82
+ tests/test_hollerith.py
80
83
  tests/test_inspect_dialect.py
81
84
  tests/test_invariants.py
82
85
  tests/test_ir_contract.py
@@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta"
13
13
 
14
14
  [project]
15
15
  name = "nativegate"
16
- version = "0.1.7"
16
+ version = "0.1.9"
17
17
  description = "Make legacy C++/C/Fortran AI-ready and microservice-ready: expose native code as an MCP server, REST API and container"
18
18
  readme = "README.md"
19
19
  requires-python = ">=3.10"
@@ -0,0 +1,79 @@
1
+ """D14: `external` dummies are callbacks — refusing to type them.
2
+
3
+ First contact: netlib quadpack QAGSE (f2py itself refuses — 'external f
4
+ is not in lcb_map[]'). The regex reader's IMPLICIT fallback typed 'f' as
5
+ `real` -> float: a binding that builds, imports, passes its smoke test
6
+ and hands the Fortran side garbage at call time. Both backends now
7
+ refuse the routine as a whole with the reason; ModuleIR.skipped carries
8
+ it. NOTE: the synthetic decks are written with hard six-space margins —
9
+ textwrap.dedent strips the shared indent and the files stop being
10
+ fixed-form.
11
+ """
12
+
13
+ from pathlib import Path
14
+
15
+ from nativegate.config import ExposeConfig
16
+ from nativegate.parsers import fortran as fortran_parser
17
+
18
+ HEADER_SUB = (
19
+ " SUBROUTINE QNG(F, A, B)\n"
20
+ " EXTERNAL F\n"
21
+ " REAL A, B\n"
22
+ " END\n"
23
+ )
24
+
25
+ HEADER_FREE = (
26
+ "subroutine qng(f, a, b)\n"
27
+ " external f\n"
28
+ " real a, b\n"
29
+ "end subroutine qng\n"
30
+ )
31
+
32
+ HEADER_PLAIN = (
33
+ " SUBROUTINE ASSOC(A, B)\n"
34
+ " REAL A, B\n"
35
+ " A = B\n"
36
+ " RETURN\n"
37
+ " END\n"
38
+ )
39
+
40
+
41
+ def test_regex_backend_reports_external_dummy_as_skip(tmp_path):
42
+ src = tmp_path / "qng.f"
43
+ src.write_text(HEADER_SUB)
44
+ module = fortran_parser.parse_source(
45
+ src, ExposeConfig(functions=["QNG"]), backend="regex"
46
+ )
47
+ assert module.functions == []
48
+ assert len(module.skipped) == 1
49
+ assert module.skipped[0].name == "QNG"
50
+ assert "EXTERNAL" in module.skipped[0].reason
51
+
52
+
53
+ def test_fparser_backend_reports_external_dummy_as_skip(tmp_path):
54
+ src = tmp_path / "qng.f"
55
+ src.write_text(HEADER_SUB)
56
+ module = fortran_parser.parse_source(
57
+ src, ExposeConfig(functions=["QNG"]), backend="fparser2"
58
+ )
59
+ assert module.functions == []
60
+ assert "EXTERNAL" in module.skipped[0].reason
61
+
62
+
63
+ def test_free_form_backend_also_refuses_callbacks(tmp_path):
64
+ src = tmp_path / "qng.f90"
65
+ src.write_text(HEADER_FREE)
66
+ module = fortran_parser.parse_source(
67
+ src, ExposeConfig(functions=["qng"]), backend="regex"
68
+ )
69
+ assert module.functions == []
70
+ assert len(module.skipped) == 1
71
+
72
+
73
+ def test_routines_without_callbacks_still_bind(tmp_path):
74
+ src = tmp_path / "g.f"
75
+ src.write_text(HEADER_PLAIN)
76
+ module = fortran_parser.parse_source(
77
+ src, ExposeConfig(functions=["ASSOC"]), backend="regex"
78
+ )
79
+ assert [p.name.lower() for p in module.functions[0].parameters] == ["a", "b"]
@@ -0,0 +1,84 @@
1
+ """D15: Hollerith constants resolve so fparser2 reads gfortran-acceptable files.
2
+
3
+ `call xerror(26habnormal return from qng ,26,ier,0)` compiles under
4
+ gfortran with a legacy-extension warning but made fparser2 refuse the
5
+ whole file — QUADPACK's QNG, un-inspectable at the default backend. The
6
+ front door now resolves these into the standard quoted spelling in a temp
7
+ copy attributed back to the original path (same module name, same source
8
+ file reference, same line count), so both `inspect` and `generate` see
9
+ the file the compiler sees.
10
+ """
11
+
12
+ import textwrap
13
+ from pathlib import Path
14
+
15
+ from click.testing import CliRunner
16
+
17
+ from nativegate import cli
18
+ from nativegate.parsers import fortran as fortran_parser
19
+ from nativegate.parsers import hollerith
20
+ from nativegate.config import ExposeConfig
21
+
22
+ QNG_WITH_HOLLERITH = (
23
+ " SUBROUTINE QNG(F, A, B)\n"
24
+ " EXTERNAL F\n"
25
+ " REAL A, B\n"
26
+ " IF (IER.NE.0) THEN\n"
27
+ " 80 CALL XERROR(26HABNORMAL RETURN FROM QNG ,26,IER,0)\n"
28
+ " END IF\n"
29
+ " RETURN\n"
30
+ " END\n"
31
+ )
32
+
33
+
34
+ def test_resolver_quotes_and_preserves_columns():
35
+ source = QNG_WITH_HOLLERITH
36
+ out, resolved = hollerith.resolve_hollerith(source)
37
+ assert resolved == 1
38
+ assert "26HABNORMAL" not in out
39
+ assert "'ABNORMAL RETURN FROM QNG '" in out
40
+ # line count and margin are invariants: every physical line stays
41
+ assert out.count("\n") == source.count("\n")
42
+ for original, rewritten in zip(source.splitlines(), out.splitlines()):
43
+ assert len(rewritten) <= 72
44
+
45
+
46
+ def test_resolver_leaves_continuation_spanning_constants():
47
+ # the constant counts past the physical line: untouched, so line
48
+ # numbers never shift
49
+ text = " CALL XERROR(6HABCDE\n -F,6,IER,0)\n"
50
+ out, resolved = hollerith.resolve_hollerith(text)
51
+ assert resolved == 0
52
+ assert out == text
53
+
54
+
55
+ def test_resolver_is_a_noop_without_hollerith():
56
+ text = " REAL A\n A = 2.0\n"
57
+ out, resolved = hollerith.resolve_hollerith(text)
58
+ assert resolved == 0 and out == text
59
+
60
+
61
+ def test_quote_escapes_are_balanced():
62
+ assert hollerith._quote("it's") == '"it\'s"'
63
+ assert hollerith._quote('say "hi"') == '\'say "hi"\''
64
+
65
+
66
+ def test_inspect_parses_hollerith_deck_by_default(tmp_path):
67
+ src = tmp_path / "qng.f"
68
+ src.write_text(QNG_WITH_HOLLERITH)
69
+ # the ONE check that matters: no explicit backend flag, the default
70
+ # (fparser2 where importable) reads the file the compiler reads
71
+ result = CliRunner().invoke(
72
+ cli.main, ["inspect", str(src), "--function", "QNG"]
73
+ )
74
+ assert result.exit_code == 0, result.output
75
+ # the callback is refused with its reason, never mis-typed (D14)
76
+ assert "EXTERNAL" in result.output
77
+
78
+
79
+ def test_module_name_attributed_to_real_file(tmp_path):
80
+ src = tmp_path / "qng.f"
81
+ src.write_text(QNG_WITH_HOLLERITH)
82
+ module = fortran_parser.parse_source(src, ExposeConfig(functions=["QNG"]))
83
+ assert module.name == "qng"
84
+ assert module.source_file == str(src)
File without changes
File without changes
File without changes