diff --git a/README.md b/README.md index b5214c52c..2343d5dda 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ -# PRIK - -**Python Runtime Interop Kit.** +# PRIK — Python Runtime Interop Kit **Turn Fortran into natural Python APIs.** -Build clean, importable native extensions from supported Fortran without -writing low-level binding code. PRIK preserves modules, derived types, arrays, -and native behavior, and generates an editable `.pyi` contract so you can -shape the Python API. +PRIK wraps Fortran code into clean, importable native extensions for Python +without requiring you to write low-level binding code. + +It is a Fortran-to-Python binding generator that preserves modules, derived +types, arrays, callbacks, and native behavior, and generates an editable +`.pyi` contract so you can shape the resulting Python API. **Project status: Alpha (`0.1.x`).** Core Fortran wrapper workflows are implemented and tested across supported compilers, but public APIs may still diff --git a/prik/preprocessing_test_example.md b/docs/developer/compiler-preprocessing.md similarity index 85% rename from prik/preprocessing_test_example.md rename to docs/developer/compiler-preprocessing.md index b05722300..96228582d 100644 --- a/prik/preprocessing_test_example.md +++ b/docs/developer/compiler-preprocessing.md @@ -1,4 +1,13 @@ -# Compiler-Backed Preprocessing Notes +--- +title: Compiler Preprocessing Reference +audience: developers, contributors +prerequisites: repository structure, native project compiler flags +related: source-map.md, c-parser-reference.md, fortran-parser-reference.md, ../user/examples/recipes/compiler-preprocessing.md +status: maintained +publication: draft +--- + +# Compiler-Backed Preprocessing Reference `prik/pipeline/preprocessing.py` owns compiler-backed preprocessing for the wrapper pipeline. The parsers consume one expanded source stream; they do not evaluate @@ -40,8 +49,7 @@ Built-in adapters cover GCC-compatible C/Clang (`-E -x c`), GNU Fortran compiler families. A custom template must write expanded source to stdout: ```bash -python -m prik parse include/api.h --language c \ - --preprocess compiler \ +python -m prik parse include/api.h --language c --preprocess compiler \ --preprocessor-adapter command-template \ --preprocess-template 'vendor-cc --preprocess {include_dirs} {defines} {source}' ``` diff --git a/docs/developer/feature-to-code-map.md b/docs/developer/feature-to-code-map.md index e5e664080..daa51f4e5 100644 --- a/docs/developer/feature-to-code-map.md +++ b/docs/developer/feature-to-code-map.md @@ -28,7 +28,7 @@ before documentation may call the behavior supported. ```python -from importlib.util import module_from_spec, spec_from_file_location from pathlib import Path from tempfile import TemporaryDirectory @@ -28,9 +28,7 @@ from prik import build_fortran_extension source = Path("tests/fortran/building_shared_library/end_to_end/fixtures/native/fruntime_abi_f90.f90") with TemporaryDirectory() as output_dir: build = build_fortran_extension(source, output_dir=output_dir) - spec = spec_from_file_location(build.module_name, build.shared_library) - module = module_from_spec(spec) - spec.loader.exec_module(module) + module = build.import_module() native_module = module.fruntime_abi_f90 print(build.module_name) @@ -47,7 +45,11 @@ fruntime_abi_f90 ## Notes -- This pattern avoids editing `sys.path`. +- `import_module()` avoids editing `sys.path` and registers the extension under + `build.module_name` in the normal Python module cache. +- The shared-library file must exist. Direct builds can import immediately; + Makefile and source-only results can import after their extension has been + built. - `TemporaryDirectory` keeps documentation and tests from leaving build artifacts in the checkout. - Use the returned artifact paths when debugging generated code. diff --git a/docs/user/examples/recipes/compiler-preprocessing.md b/docs/user/examples/recipes/compiler-preprocessing.md index 34f3744ff..61e0ee20e 100644 --- a/docs/user/examples/recipes/compiler-preprocessing.md +++ b/docs/user/examples/recipes/compiler-preprocessing.md @@ -2,7 +2,7 @@ title: Use Compiler Preprocessing Options audience: users, developers prerequisites: installation, native project compiler flags -related: ../../../developer/c-parser-reference.md, ../../../developer/fortran-parser-reference.md +related: ../../../developer/compiler-preprocessing.md, ../../../developer/c-parser-reference.md, ../../../developer/fortran-parser-reference.md status: maintained publication: draft --- @@ -40,6 +40,8 @@ PRIK_C_DOCS_END --> ## Notes +- For the pipeline model, adapters, diagnostics, and include-exposure policy, + see the [compiler preprocessing reference](../../../developer/compiler-preprocessing.md). - Pass the same important include paths, macros, and target flags used by the native project. - Compiler-backed semantic and `.pyi` stages can also probe target datatype diff --git a/docs/user/reference/python-api.md b/docs/user/reference/python-api.md index 314ed2bc9..f775e1a2a 100644 --- a/docs/user/reference/python-api.md +++ b/docs/user/reference/python-api.md @@ -187,7 +187,7 @@ plain NumPy arrays are for ordinary array-data parameters. | `build_fortran_extension` | Builds a Python extension from semantic Fortran source inputs plus optional native-only sources, artifacts, compiler flags, include paths, libraries, and ordered link items. | | `build_pyi_extension` | Builds a Python extension from semantic `.pyi` contracts plus explicit native artifacts. | | `build_pyi_extension_from_manifest` | Replays a saved semantic `.pyi` wrapper build manifest, either building directly or regenerating `Makefile.prik`. | -| `WrapperBuildResult` | Result model returned by wrapper build functions. | +| `WrapperBuildResult` | Result model returned by wrapper build functions; `import_module()` explicitly loads its built extension. | | `NativeBuildPlan` | Structured native implementation compile/link plan attached to a wrapper build result. | | `NativeCompilationUnit` | Native source compilation unit and produced object recorded in a native build plan. | | `NativePrebuiltArtifact` | Caller-supplied native object, archive, or shared library recorded in a native build plan. | @@ -205,6 +205,13 @@ the semantic contract paths. Semantic `.pyi` build results also expose a normalized replay `manifest`; Makefile mode writes that manifest to `/prik-build.json` before generating `Makefile.prik`. +When a program needs the generated extension immediately, call +`result.import_module()`. It loads `result.shared_library` under +`result.module_name` without changing `sys.path` and returns the imported +module. The method requires that the shared-library file already exists, so a +direct build can import at once and a Makefile result can import after `make` +has produced the extension. + ## Target type and NumPy helpers | Symbol | Purpose | diff --git a/mkdocs.yml b/mkdocs.yml index acd1c3339..7ff5bafdf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -117,6 +117,7 @@ nav: - Source Map: developer/source-map.md - Feature To Code Map: developer/feature-to-code-map.md - Repository Structure: developer/repository-structure.md + - Compiler Preprocessing Reference: developer/compiler-preprocessing.md # PRIK_C_DOCS: - C Parser Reference: developer/c-parser-reference.md - Fortran Parser Reference: developer/fortran-parser-reference.md - Quality Assurance: developer/quality-assurance.md diff --git a/prik/extensions/__init__.py b/prik/extensions/__init__.py deleted file mode 100644 index 163ff8b85..000000000 --- a/prik/extensions/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Optional third-party extension resources used by the compiler pipeline.""" diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index a5b1a1cb8..7d08d0737 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -5,11 +5,14 @@ from collections.abc import Callable, Iterable from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import dataclass, field, replace +from importlib.util import module_from_spec, spec_from_file_location import json import os from pathlib import Path import shlex +import sys import time +from types import ModuleType from prik.compiling.objects import ObjectFile from prik.compiling.compilers import Compiler, get_condaless_search_path @@ -68,6 +71,9 @@ } +# Build configuration, timing, and mode validation + + def _print_verbose_timing(verbose: bool | int, elapsed: float) -> None: """Print the elapsed time for the immediately preceding build operation.""" if verbose: @@ -116,9 +122,47 @@ def _normalize_compile_jobs(jobs: int | None) -> int: return jobs +def _resolve_build_mode( + *, + makefile: bool, + generate_sources: bool, + jobs: int | None, + verbose: bool | int, +) -> tuple[bool, int]: + """Validate build-output mode and resolve its compiler job limit.""" + if makefile and generate_sources: + raise ValueError("source-only and Makefile generation are mutually exclusive") + generation_only = makefile or generate_sources + compile_jobs = _normalize_compile_jobs(jobs) + if generation_only and verbose: + raise ValueError("source/Makefile generation and verbose direct compilation are separate modes") + return generation_only, compile_jobs + + +# Public build records + + @dataclass(frozen=True) class NativeCompilationUnit: - """One caller-supplied native source and the object it produces.""" + """Describe one native source file that a wrapper build must compile. + + Use this record to inspect the compilation portion of + :attr:`WrapperBuildResult.native_build_plan`; callers normally provide the + corresponding ``native_fortran_sources`` and ``native_fortran_flags`` to a + build entrypoint rather than construct this record themselves. + + Parameters + ---------- + source + Native source path passed to the compiler. + object_path + Object path produced in the build directory. + language + Compiler language selected for ``source``. + module_dir, include_dirs, flags + Module-output location, header/module search paths, and per-source + compiler flags recorded for reproducible builds. + """ source: Path object_path: Path @@ -128,6 +172,12 @@ class NativeCompilationUnit: flags: tuple[str, ...] = () def __post_init__(self) -> None: + """Normalize path and flag fields after dataclass construction. + + The caller may supply path-like values and any iterable of flags. The + frozen record is changed in place with ``Path`` and tuple values so + later manifest and compiler code can rely on a stable representation. + """ object.__setattr__(self, "source", Path(self.source)) object.__setattr__(self, "object_path", Path(self.object_path)) if self.module_dir is not None: @@ -136,6 +186,11 @@ def __post_init__(self) -> None: object.__setattr__(self, "flags", tuple(str(flag) for flag in self.flags)) def to_dict(self) -> dict[str, object]: + """Return a JSON-ready representation of this compilation unit. + + Paths become strings and tuples become lists. The method does not + write a manifest or modify the compilation unit. + """ return { "source": str(self.source), "object": str(self.object_path), @@ -148,17 +203,30 @@ def to_dict(self) -> dict[str, object]: @dataclass(frozen=True) class NativePrebuiltArtifact: - """One caller-supplied native artifact used by the extension link.""" + """Describe one existing object, archive, or shared library to link. + + Build callers usually pass the artifact path through ``native_objects``. + This record appears in the resulting native build plan, where ``kind`` is + one of ``"object"``, ``"archive"``, or ``"shared_library"``. + """ path: Path kind: str def __post_init__(self) -> None: + """Validate the artifact kind and normalize its path field. + + Raises + ------ + ValueError + If ``kind`` cannot be represented as a filesystem link input. + """ if self.kind not in _NATIVE_PATH_LINK_KINDS: raise ValueError(f"Unsupported native artifact kind: {self.kind!r}") object.__setattr__(self, "path", Path(self.path)) def to_dict(self) -> dict[str, object]: + """Return the artifact kind and string path for JSON serialization.""" return { "kind": self.kind, "path": str(self.path), @@ -167,12 +235,24 @@ def to_dict(self) -> dict[str, object]: @dataclass(frozen=True) class NativeLinkItem: - """One ordered item in the native implementation link plan.""" + """Describe one ordered linker input for a wrapper extension. + + Pass these records through ``native_link_items`` when link order matters. + Filesystem items use ``"object"``, ``"archive"``, or + ``"shared_library"`` with a path value; ``"named_library"`` uses a bare + library name and ``"linker_argument"`` passes an argument through + unchanged. + """ kind: str value: Path | str def __post_init__(self) -> None: + """Validate ``kind`` and normalize its value to a path or string. + + Path-based items retain a ``Path`` for file validation. Named + libraries and raw linker arguments retain strings for command output. + """ if self.kind not in _NATIVE_LINK_KINDS: raise ValueError(f"Unsupported native link item kind: {self.kind!r}") if self.kind in _NATIVE_PATH_LINK_KINDS: @@ -181,6 +261,11 @@ def __post_init__(self) -> None: object.__setattr__(self, "value", str(self.value)) def to_dict(self) -> dict[str, object]: + """Return the item in the dictionary shape accepted by build APIs. + + Path-based kinds use ``path``, named libraries use ``name``, and raw + arguments use ``argument``. The record itself remains unchanged. + """ if self.kind in _NATIVE_PATH_LINK_KINDS: return { "kind": self.kind, @@ -199,7 +284,13 @@ def to_dict(self) -> dict[str, object]: @dataclass(frozen=True) class NativeBuildPlan: - """Extension-level native implementation build and link plan.""" + """Record the native compilation and link inputs selected for a build. + + Inspect :attr:`WrapperBuildResult.native_build_plan` to learn which native + sources will compile, which artifacts and libraries will link, and which + include or module directories the generated wrapper uses. It is an + immutable build report, not a command executor. + """ compilation_units: tuple[NativeCompilationUnit, ...] = () produced_objects: tuple[Path, ...] = () @@ -210,6 +301,11 @@ class NativeBuildPlan: link_items: tuple[NativeLinkItem, ...] = () def __post_init__(self) -> None: + """Normalize every collection and filesystem field in this plan. + + This converts accepted iterable/path-like constructor values to tuples + and ``Path`` objects. No files are created, compiled, or linked. + """ object.__setattr__(self, "compilation_units", tuple(self.compilation_units)) object.__setattr__(self, "produced_objects", tuple(Path(path) for path in self.produced_objects)) object.__setattr__(self, "prebuilt_artifacts", tuple(self.prebuilt_artifacts)) @@ -219,6 +315,7 @@ def __post_init__(self) -> None: object.__setattr__(self, "link_items", tuple(self.link_items)) def to_dict(self) -> dict[str, object]: + """Return a complete JSON-ready snapshot of the native build plan.""" return { "compilation_units": [unit.to_dict() for unit in self.compilation_units], "produced_objects": [str(path) for path in self.produced_objects], @@ -232,7 +329,16 @@ def to_dict(self) -> dict[str, object]: @dataclass(frozen=True) class WrapperBuildResult: - """Artifacts produced by one wrapper build.""" + """Report the generated artifacts and mode selected by one build call. + + Every public build entrypoint returns this record. Check ``compiled`` to + distinguish a built extension from ``generate_sources=True`` or + ``makefile=True`` output; then use ``shared_library``, + ``generated_sources``, ``build_makefile``, and ``build_manifest`` as + applicable. Call :meth:`import_module` to explicitly load an existing + extension artifact. ``native_build_plan`` explains the native inputs that + were compiled or linked. + """ sources: tuple[Path, ...] module_name: str @@ -246,7 +352,57 @@ class WrapperBuildResult: build_manifest: Path | None = None manifest: dict[str, object] | None = None + def import_module(self) -> ModuleType: + """Import and return this result's built extension module. + + The shared-library artifact is loaded under ``module_name`` without + changing ``sys.path``. Direct-build results can be imported + immediately; source-only and Makefile results become importable after + their shared-library path exists. Repeated calls return the cached + module for the same artifact. A different module already cached under + the same name raises ``ImportError`` instead of silently returning it. + + Raises + ------ + FileNotFoundError + If ``shared_library`` has not been built yet. + ImportError + If Python cannot create a loader for the artifact or the module + name is already bound to a different module. + """ + if not self.shared_library.is_file(): + raise FileNotFoundError(f"Built extension not found: {self.shared_library}") + + cached_module = sys.modules.get(self.module_name) + if cached_module is not None: + cached_path = getattr(cached_module, "__file__", None) + if cached_path is not None and Path(cached_path).resolve(strict=False) == self.shared_library.resolve( + strict=False + ): + return cached_module + raise ImportError( + f"Cannot import {self.shared_library}: module name {self.module_name!r} " + f"is already bound to {cached_path!r}" + ) + + spec = spec_from_file_location(self.module_name, self.shared_library) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot create an import loader for {self.shared_library}") + module = module_from_spec(spec) + sys.modules[self.module_name] = module + try: + spec.loader.exec_module(module) + except BaseException: + sys.modules.pop(self.module_name, None) + raise + return module + def to_dict(self) -> dict[str, object]: + """Return all result paths and nested plans in JSON-ready form. + + This is suitable for logging or caller-owned serialization. It does + not write a file, trigger compilation, or mutate the result. + """ return { "sources": [str(source) for source in self.sources], "module_name": self.module_name, @@ -262,7 +418,16 @@ def to_dict(self) -> dict[str, object]: } +# Shared build utilities + + def _default_preprocessing_config() -> PreprocessingConfig: + """Create the default compiler-backed preprocessing configuration. + + The build pipeline calls this only when a caller did not provide a + ``PreprocessingConfig``. It returns a fresh configuration for ``gfortran`` + so a build cannot modify shared default lists. + """ return PreprocessingConfig( mode="compiler", compiler="gfortran", @@ -272,13 +437,23 @@ def _default_preprocessing_config() -> PreprocessingConfig: def _fortran_source_for_pipeline(path: Path, preprocessing: PreprocessingConfig) -> str: + """Read one source path in the form required by the Fortran parser. + + Compiler-backed preprocessing produces the expanded source text; other + modes read UTF-8 text directly. The helper reads ``path`` but does not + change the source file or preprocessing configuration. + """ if preprocessing.uses_compiler: return preprocess_source(path, language="fortran", config=preprocessing).source return path.read_text(encoding="utf-8") def _compiler_flags(flags: Iterable[str] | None) -> tuple[str, ...]: - """Normalize caller-supplied compiler flags.""" + """Normalize optional caller compiler flags into an immutable tuple. + + ``None`` becomes an empty tuple and each supplied value becomes a string. + The helper consumes the iterable without invoking a compiler. + """ return tuple(str(flag) for flag in (flags or ())) @@ -288,6 +463,13 @@ def _new_compiler( debug: bool = False, input_compiler: str | None = None, ) -> Compiler: + """Create the compiler configured for generated wrapper code. + + ``input_compiler`` overrides the default ``gfortran`` executable; + ``execute_commands`` selects real compilation versus command recording, + and ``debug`` enables the compiler's debug configuration. The returned + compiler has a Conda-free search path and has not run any commands yet. + """ return Compiler.from_fortran_executable( input_compiler or "gfortran", debug=debug, @@ -296,6 +478,22 @@ def _new_compiler( ) +def _validated_wrapper_module_name(requested_name: str | None, default_name: str) -> str: + """Choose a requested or default extension name and validate it. + + Returns the requested name when present, otherwise ``default_name``. A + non-identifier cannot be imported as a Python extension and raises + ``ValueError`` before files are generated. + """ + module_name = requested_name or default_name + if not module_name.isidentifier(): + raise ValueError(f"Output name must be a valid Python identifier: {module_name!r}") + return module_name + + +# Rendered wrapper artifacts and native compilation + + def _expected_generated_files( *, source_objects: tuple[ObjectFile, ...], @@ -303,6 +501,12 @@ def _expected_generated_files( module_name: str, shared_library: Path, ) -> tuple[Path, ...]: + """Collect the build artifacts that currently exist on disk. + + Combines caller-native object paths, expected generated bridge/binding + files, the shared library, and installed native-support files. Missing + optional outputs are omitted; this helper only reads filesystem state. + """ candidates = [ *(source_obj.object_path for source_obj in source_objects), output_dir / f"bind_c_{module_name}.mod", @@ -462,11 +666,19 @@ def _rendered_wrapper_link_language( @dataclass(frozen=True) class _CompiledObject: + """Store the recorded compiler command and elapsed time for one object.""" + command: tuple[str, ...] | None elapsed: float def _compile_one_object(compiler: Compiler, object_file: ObjectFile) -> _CompiledObject: + """Compile one object and return its command record plus elapsed time. + + The supplied ``compiler`` performs the compile and may create the object + file. A tuple command is retained for Makefile generation; other compiler + return values are represented as ``None``. + """ started = time.perf_counter() command = compiler.compile_object(object_file, verbose=False) return _CompiledObject( @@ -482,6 +694,12 @@ def _report_compiled_object( label: str, verbose: bool | int, ) -> None: + """Print verbose diagnostics for one completed object compilation. + + Receives the object and timing record produced by ``_compile_one_object``. + When ``verbose`` is false it changes nothing; otherwise it writes the + labelled source-to-object mapping, command, and duration to standard out. + """ if not verbose: return _print_verbose_step(verbose, f"{label}: {object_file.source} -> {object_file.object_path}") @@ -508,6 +726,11 @@ def _submit_object_stage( compiler: Compiler, object_files: Iterable[ObjectFile], ) -> tuple[tuple[ObjectFile, Future[_CompiledObject]], ...]: + """Submit one independent compilation group to an executor. + + Each input object produces one ``(object_file, future)`` pair. The helper + schedules work but does not wait for it or report verbose output. + """ return tuple( (object_file, executor.submit(_compile_one_object, compiler, object_file)) for object_file in object_files ) @@ -519,6 +742,12 @@ def _finish_object_stage( label: str, verbose: bool | int, ) -> None: + """Wait for a submitted compilation group and report each result. + + ``pending`` comes from ``_submit_object_stage``. Calling ``future.result`` + propagates compiler failures; successful objects are reported in input + order when verbose output is enabled. + """ for object_file, future in pending: _report_compiled_object(object_file, future.result(), label=label, verbose=verbose) @@ -570,6 +799,7 @@ def _build_rendered_wrapper_extension( verbose: bool | int = False, ) -> WrapperBuildResult: """Build one extension from rendered wrapper-plan artifacts.""" + # Materialize the canonical wrapper output before creating compiler inputs. rendered.freeze() output_path = Path(output_dir) output_path.mkdir(parents=True, exist_ok=True) @@ -577,6 +807,7 @@ def _build_rendered_wrapper_extension( shared_output_path.mkdir(parents=True, exist_ok=True) _write_rendered_wrapper_sources(rendered, output_path, verbose=verbose) + # Prepare generated-object inputs and their native support files. compiler = compiler or _new_compiler() resolved_native_build_plan = native_build_plan or NativeBuildPlan() bridge_objects, binding_objects = _rendered_wrapper_object_stages( @@ -597,6 +828,8 @@ def _build_rendered_wrapper_extension( prik_dirpath=str(output_path), verbose=verbose, ) + + # Compile dependency-ready native sources, then the bridge and binding. _compile_extension_objects( compiler, native_batches=native_compile_batches, @@ -606,6 +839,7 @@ def _build_rendered_wrapper_extension( verbose=verbose, ) + # Link the generated and caller-supplied objects into the extension. linking_started = time.perf_counter() shared_library = compiler.link_extension( module_name=rendered.artifacts.module_name, @@ -618,12 +852,12 @@ def _build_rendered_wrapper_extension( verbose=verbose, ) _print_verbose_timing(verbose, time.perf_counter() - linking_started) - generated_sources = tuple( + generated_source_paths = tuple( path for path in rendered.artifacts.generated_files if _rendered_artifact_output_path(output_path, path).exists() ) - generated_sources = tuple(_rendered_artifact_output_path(output_path, path) for path in generated_sources) + generated_sources = tuple(_rendered_artifact_output_path(output_path, path) for path in generated_source_paths) return WrapperBuildResult( sources=tuple(Path(source) for source in sources), module_name=rendered.artifacts.module_name, @@ -668,6 +902,30 @@ def _attach_build_makefile( ) +def _finalize_build_mode( + result: WrapperBuildResult, + *, + makefile: bool, + generate_sources: bool, + compiler: Compiler, + source_objects: tuple[ObjectFile, ...], + extra_dependencies: tuple[Path, ...] = (), + build_manifest: Path | None = None, +) -> WrapperBuildResult: + """Turn a planned build into its requested source-only or Makefile result.""" + if makefile: + return _attach_build_makefile( + result, + compiler=compiler, + source_objects=source_objects, + extra_dependencies=extra_dependencies, + build_manifest=build_manifest, + ) + if generate_sources: + return replace(result, compiled=False) + return result + + def _render_wrapper_plan( module: SemanticModule, *, @@ -691,6 +949,12 @@ def _generated_wrapper_plan_artifacts( _print_verbose_timing(verbose, time.perf_counter() - policy_started) def render_progress(label: str, elapsed: float | None) -> None: + """Translate generator progress events into this build's verbose output. + + A missing duration starts a labelled step; a present duration completes + the previous step's timing. The callback only writes optional console + output and does not affect generation. + """ if elapsed is None: _print_verbose_step(verbose, label) return @@ -699,6 +963,9 @@ def render_progress(label: str, elapsed: float | None) -> None: return _render_wrapper_plan(module, progress=render_progress) +# Native source compilation scheduling + + def _source_compile_object( source_path: Path, output_dir: Path, @@ -707,6 +974,13 @@ def _source_compile_object( flags: Iterable[str] = (), include_dirs: Iterable[Path] = (), ) -> ObjectFile: + """Describe the object compilation for one caller-native source. + + Uses ``object_stem`` beneath ``output_dir`` to avoid collisions, preserves + the supplied flags, and appends the output directory to include paths so + later sources can locate generated Fortran module files. It returns only + an ``ObjectFile`` description and does not compile it. + """ target = output_dir / f"{object_stem}.o" return ObjectFile( source=source_path, @@ -718,10 +992,22 @@ def _source_compile_object( def _serial_compile_batches(object_files: Iterable[ObjectFile]) -> tuple[tuple[ObjectFile, ...], ...]: + """Place each object in its own ordered compilation batch. + + This conservative fallback consumes ``object_files`` and returns singleton + tuples, ensuring that a caller compiles sources serially when dependency + information is unavailable or cyclic. + """ return tuple((object_file,) for object_file in object_files) def _fortran_owner_used_modules(owner: object) -> set[str]: + """Return lowercased modules used directly or indirectly by one owner. + + ``owner`` may be a parsed module, program, procedure, or submodule. The + helper reads its ``uses`` mappings and the uses of contained procedures and + interface procedures, returning a new set without changing the parsed AST. + """ used = {str(name).lower() for name in getattr(owner, "uses", {})} for procedure in getattr(owner, "procedures", ()): used.update(str(name).lower() for name in getattr(procedure, "uses", {})) @@ -732,6 +1018,12 @@ def _fortran_owner_used_modules(owner: object) -> set[str]: def _fortran_file_used_modules(parsed_file: object) -> set[str]: + """Return lowercased module dependencies declared by one parsed file. + + Scans top-level parsed owners, interfaces, and submodule parent/ancestor + relationships. The returned names let the scheduler order object files; + the parsed file remains unmodified. + """ owners = ( *getattr(parsed_file, "modules", ()), *getattr(parsed_file, "submodules", ()), @@ -755,6 +1047,13 @@ def _dependency_compile_batches( object_files: tuple[ObjectFile, ...], dependencies: dict[Path, set[Path]], ) -> tuple[tuple[ObjectFile, ...], ...]: + """Topologically group object files into safe parallel compile batches. + + ``dependencies`` maps normalized source paths to provider source paths. + Every returned batch depends only on earlier batches. If no source is + ready, a cycle or incomplete graph is present, so the function returns the + serial fallback rather than guess an unsafe order. + """ object_by_source = {_path_key(object_file.source): object_file for object_file in object_files} remaining = list(object_by_source) completed: set[Path] = set() @@ -774,6 +1073,7 @@ def _project_compile_batches( object_files: tuple[ObjectFile, ...], ) -> tuple[tuple[ObjectFile, ...], ...]: """Group parsed project objects into dependency-ready compiler batches.""" + # Every compiled source must correspond to one parsed project file. parsed_files = tuple(getattr(parsed_project, "files", ())) parsed_by_source = { _path_key(Path(parsed_file.filename)): parsed_file @@ -784,6 +1084,7 @@ def _project_compile_batches( if set(parsed_by_source) != object_sources: return _serial_compile_batches(object_files) + # Map providers before resolving each file's module dependencies. module_sources: dict[str, Path] = {} for source, parsed_file in parsed_by_source.items(): for module in getattr(parsed_file, "modules", ()): @@ -801,7 +1102,17 @@ def _project_compile_batches( return _dependency_compile_batches(object_files, dependencies) +# Source and semantic-contract inputs + + def _source_paths(sources: str | Path | Iterable[str | Path]) -> tuple[Path, ...]: + """Validate and expand wrapper source inputs into a unique ordered tuple. + + A file must have a supported Fortran suffix; a directory is recursively + expanded in sorted order. The result preserves the caller's input order + while removing repeated paths. Missing files, empty directories, and + unsupported suffixes raise clear input errors before parsing begins. + """ inputs = (Path(sources),) if isinstance(sources, str | Path) else tuple(Path(source) for source in sources) if not inputs: raise ValueError("wrapper build requires at least one Fortran source file or directory") @@ -837,6 +1148,12 @@ def _wrapper_output_paths(output_dir: str | Path | None) -> tuple[Path, Path]: def _pyi_entry_path(contract: str | Path) -> Path: + """Validate and return the single semantic ``.pyi`` entry contract path. + + The public ``build_pyi_extension`` API accepts exactly one existing + ``.pyi`` file. This helper rejects collections, other suffixes, and + missing paths, then returns the unmodified ``Path`` for contract loading. + """ if not isinstance(contract, str | Path): raise TypeError(".pyi wrapper build accepts exactly one entry contract path") path = Path(contract) @@ -849,6 +1166,8 @@ def _pyi_entry_path(contract: str | Path) -> Path: @dataclass(frozen=True) class _PyiContractBundle: + """Keep one resolved ``.pyi`` import graph and its native contract leaves.""" + entry: Path leaves: tuple[Path, ...] paths: tuple[Path, ...] @@ -857,6 +1176,8 @@ class _PyiContractBundle: @dataclass(frozen=True) class _NativeBuildInputs: + """Hold validated native source, artifact, include, and link input groups.""" + source_paths: tuple[Path, ...] source_flags: tuple[str, ...] artifact_paths: tuple[Path, ...] @@ -868,9 +1189,20 @@ class _NativeBuildInputs: explicit_include_dirs: tuple[Path, ...] +# Semantic `.pyi` contract loading and export projection + + def _pyi_contract_bundle( entry: Path, ) -> _PyiContractBundle: + """Load one semantic contract graph and retain its native declaration leaves. + + Starting at ``entry``, this resolves relative imports through one cache, + validates package-placement rules, projects Python exports, and validates + native contracts. It returns the entry, all discovered paths, and only + modules with native declarations; no generated sources are written. + """ + # Load the complete relative-import graph through one semantic-module cache. module_cache = _PyiSemanticModuleCache() discovered = {entry, *_discover_pyi_imports(entry, module_cache)} sorted_paths = tuple(sorted(discovered)) @@ -878,6 +1210,8 @@ def _pyi_contract_bundle( modules_by_path = dict(zip(sorted_paths, loaded_modules, strict=True)) _validate_pyi_bundle_placement(entry, modules_by_path) _apply_pyi_python_exports(entry, modules_by_path) + + # Keep only contract leaves that declare a native API to wrap. leaves = [path for path in sorted_paths if _module_has_native_declarations(modules_by_path[path])] if not leaves: raise ValueError("Entry contract does not resolve any native declarations") @@ -922,6 +1256,12 @@ def _validate_pyi_bundle_placement(entry: Path, modules_by_path: dict[Path, Sema def _declaration_is_external(declaration: object) -> bool: + """Return whether a declaration represents an external Fortran procedure. + + Individual semantic functions are external when they have no native scope; + overload sets are external only when every candidate is external. Other + declaration kinds return ``False`` and are not modified. + """ if isinstance(declaration, ProcedureOverloadSet): return bool(declaration.procedures) and all(_declaration_is_external(item) for item in declaration.procedures) if isinstance(declaration, SemanticFunction): @@ -930,6 +1270,12 @@ def _declaration_is_external(declaration: object) -> bool: def _namespace_imported_pyi_paths(entry: Path, modules_by_path: dict[Path, SemanticModule]) -> set[Path]: + """Find relative ``.pyi`` modules imported as child Python namespaces. + + Traverses the semantic import graph rooted at ``entry``. Named child + module imports are collected separately from direct declaration imports so + placement validation can enforce their different external-procedure rule. + """ namespace_imports: set[Path] = set() pending = [entry] seen: set[Path] = set() @@ -956,6 +1302,12 @@ def _namespace_imported_pyi_paths(entry: Path, modules_by_path: dict[Path, Seman def _discover_pyi_imports(root: Path, module_cache: _PyiSemanticModuleCache | None = None) -> tuple[Path, ...]: + """Resolve every relative semantic ``.pyi`` import reachable from ``root``. + + Reuses an optional semantic-module cache, follows only relative imports, + and returns sorted dependency paths excluding the root. A referenced but + missing contract raises ``FileNotFoundError`` instead of being skipped. + """ module_cache = module_cache or _PyiSemanticModuleCache() discovered: set[Path] = set() pending = [root] @@ -973,6 +1325,12 @@ def _discover_pyi_imports(root: Path, module_cache: _PyiSemanticModuleCache | No def _relative_pyi_dependencies(path: Path, module: SemanticModule) -> tuple[Path, ...]: + """Translate a module's relative imports into candidate contract paths. + + ``path`` anchors the package-relative calculation and ``module`` supplies + parsed import records. The returned paths may not exist yet; existence is + checked by the graph discovery caller. + """ dependencies: list[Path] = [] for semantic_import in module.imports: if not isinstance(semantic_import, SemanticImport) or not semantic_import.module.startswith("."): @@ -990,6 +1348,12 @@ def _relative_pyi_dependencies(path: Path, module: SemanticModule) -> tuple[Path def _pyi_dependency_path(parent: Path, dotted_name: str) -> Path: + """Choose the file or package-entry path for one relative import target. + + Forms the dotted target below ``parent`` and returns ``name.pyi`` unless + the target is an existing directory, in which case it returns its + ``__init__.pyi`` entry. It does not create either path. + """ target = parent.joinpath(*dotted_name.split(".")) module_file = target.with_suffix(".pyi") if module_file.is_file() or not target.is_dir(): @@ -998,17 +1362,27 @@ def _pyi_dependency_path(parent: Path, dotted_name: str) -> Path: def _module_has_native_declarations(module: SemanticModule) -> bool: + """Return whether a semantic module contributes any native wrapper surface.""" return bool(module.variables or module.functions or module.classes or module.overload_sets) @dataclass class _PyiExportNode: + """Represent declarations and nested Python exports at one namespace node.""" + declarations: list[object] = field(default_factory=list) children: dict[str, _PyiExportNode] = field(default_factory=dict) origins: set[Path] = field(default_factory=set) def _apply_pyi_python_exports(entry: Path, modules_by_path: dict[Path, SemanticModule]) -> None: + """Replace contract declaration export metadata with the resolved tree. + + Clears each loaded declaration's current Python exports, marks its module + as prepared, resolves exports rooted at ``entry``, and writes the resulting + namespace paths back into declaration metadata. The semantic modules are + deliberately mutated before wrapper policy completion. + """ for module in modules_by_path.values(): module.metadata[PYTHON_EXPORTS_PREPARED_METADATA] = True for declaration in _module_declarations(module): @@ -1025,6 +1399,12 @@ def _pyi_export_tree( cache: dict[Path, _PyiExportNode], pending: set[Path], ) -> _PyiExportNode: + """Build and cache the export tree rooted at one semantic contract file. + + The recursive graph combines public declarations, prototypes, and relative + imports. ``cache`` shares completed nodes, while ``pending`` detects an + import cycle and raises ``ValueError`` rather than recurse forever. + """ if path in cache: return cache[path] if path in pending: @@ -1066,6 +1446,13 @@ def _merge_relative_import( cache: dict[Path, _PyiExportNode], pending: set[Path], ) -> None: + """Merge one relative import's exports into the current namespace tree. + + Direct imports select declaration children from the dependency; child + namespace imports attach the entire dependency tree. Invalid names or + collisions are reported by the lookup and merge helpers; ``tree`` changes + in place. + """ imported_module = semantic_import.module.lstrip(".") if imported_module: dependency = _relative_import_path(path, semantic_import.module, imported_module) @@ -1087,6 +1474,7 @@ def _merge_relative_import( def _relative_import_path(path: Path, module: str, imported_module: str) -> Path: + """Resolve one dotted relative import from its importing contract path.""" level = len(module) - len(module.lstrip(".")) parent = path.parent for _ in range(level - 1): @@ -1100,12 +1488,19 @@ def _required_export_tree( cache: dict[Path, _PyiExportNode], pending: set[Path], ) -> _PyiExportNode: + """Return a dependency export tree or reject an absent imported contract.""" if path not in modules_by_path: raise FileNotFoundError(f"Imported semantic .pyi contract not found: {path}") return _pyi_export_tree(path, modules_by_path, cache=cache, pending=pending) def _merge_export_child(tree: _PyiExportNode, name: str, child: _PyiExportNode, *, origin: Path) -> None: + """Insert one named export into ``tree`` or reject a conflicting origin. + + Existing identical nodes are retained. Distinct nodes with the same name + cause a detailed ``ValueError`` naming both source origins; otherwise the + supplied child becomes part of the tree. + """ existing = tree.children.get(name) if existing is None or existing is child: tree.children[name] = child @@ -1119,6 +1514,12 @@ def _merge_export_child(tree: _PyiExportNode, name: str, child: _PyiExportNode, def _record_pyi_exports(tree: _PyiExportNode, namespace: tuple[str, ...] = ()) -> None: + """Write resolved namespace paths from an export tree into declarations. + + Walks ``tree`` recursively, skips prototypes, and appends de-duplicated + ``namespace``/``name`` records to each semantic declaration's metadata. + The declaration metadata is intentionally mutated for later planning. + """ for name, child in tree.children.items(): for declaration in child.declarations: if isinstance(declaration, SemanticPrototype): @@ -1131,10 +1532,17 @@ def _record_pyi_exports(tree: _PyiExportNode, namespace: tuple[str, ...] = ()) - def _module_declarations(module: SemanticModule) -> tuple[object, ...]: + """Return every declaration category that can receive export metadata.""" return (*module.variables, *module.functions, *module.overload_sets, *module.classes) def _declaration_metadata(declaration: object) -> dict[str, object]: + """Return the mutable metadata dictionary for one supported declaration. + + Overload sets use their first candidate's metadata because that is where + their shared export projection is stored. Unsupported objects raise + ``TypeError`` rather than silently lose metadata. + """ if isinstance(declaration, ProcedureOverloadSet): if not declaration.procedures: return {} @@ -1145,16 +1553,24 @@ def _declaration_metadata(declaration: object) -> dict[str, object]: def _declaration_exports(declaration: object) -> list[dict[str, object]]: + """Return and initialize the declaration's mutable Python export list.""" metadata = _declaration_metadata(declaration) return metadata.setdefault(PYTHON_EXPORTS_METADATA, []) def _set_declaration_exports(declaration: object, exports: list[dict[str, object]]) -> None: + """Replace one declaration's stored Python export projection in place.""" metadata = _declaration_metadata(declaration) metadata[PYTHON_EXPORTS_METADATA] = exports def _apply_source_python_exports(modules: list[SemanticModule]) -> None: + """Project direct Fortran source declarations to their Python namespaces. + + Marks every module as export-prepared and overwrites declaration metadata. + Public module members receive their module namespace; standalone public + procedures receive the root namespace; private declarations receive none. + """ for module in modules: module.metadata[PYTHON_EXPORTS_PREPARED_METADATA] = True namespace = (module.name.casefold(),) if module.origin.source_kind == "module" else () @@ -1169,12 +1585,21 @@ def _apply_source_python_exports(modules: list[SemanticModule]) -> None: ) +# Native build inputs and link planning + + def _existing_paths( paths: Iterable[str | Path] | None, *, kind: str, require_directory: bool = False, ) -> tuple[Path, ...]: + """Validate caller paths and return them as a tuple of ``Path`` values. + + Files are required by default; ``require_directory`` instead requires each + path to be a directory. Missing inputs raise a kind-specific + ``FileNotFoundError`` and no filesystem state is changed. + """ resolved = tuple(Path(path) for path in (paths or ())) for path in resolved: if require_directory: @@ -1186,6 +1611,7 @@ def _existing_paths( def _native_artifact_kind(path: Path) -> str: + """Classify a native artifact path for linker and manifest representation.""" name = path.name.lower() suffix = path.suffix.lower() if suffix in {".a", ".lib"}: @@ -1196,6 +1622,7 @@ def _native_artifact_kind(path: Path) -> str: def _unique_paths(paths: Iterable[Path]) -> tuple[Path, ...]: + """Return input paths once each, preserving their first-seen order.""" return tuple(dict.fromkeys(Path(path) for path in paths)) @@ -1212,6 +1639,13 @@ def _native_build_plan( include_dirs: tuple[Path, ...], module_dir: Path | None, ) -> NativeBuildPlan: + """Assemble the ordered native compile and link plan for one extension. + + Combines compiled source objects, prebuilt artifacts, named libraries, and + explicit or complete link items. The returned immutable plan preserves + link order and includes derived module/include directories; it does not + compile, link, or validate that prebuilt paths exist. + """ produced_objects = tuple(source_object.object_path for source_object in source_objects) source_link_items = tuple(NativeLinkItem("object", object_path) for object_path in produced_objects) prebuilt_artifacts = tuple( @@ -1252,6 +1686,12 @@ def _native_build_plan( def _native_link_args(link_items: Iterable[NativeLinkItem]) -> tuple[str, ...]: + """Convert ordered link records to the command-line arguments they require. + + Files become paths, bare named libraries acquire ``-l`` when needed, and + raw linker arguments pass through. The resulting tuple preserves input + order and is ready for the compiler linker invocation. + """ args = [] for item in link_items: if item.kind in _NATIVE_PATH_LINK_KINDS: @@ -1275,6 +1715,12 @@ def _rendered_wrapper_native_link_args(plan: NativeBuildPlan) -> tuple[str, ...] def _coerce_native_link_items(items: Iterable[NativeLinkItem | dict[str, object]] | None) -> tuple[NativeLinkItem, ...]: + """Normalize public native link-item records or dictionaries. + + Dictionary inputs must use the same ``kind`` and value-key conventions as + :meth:`NativeLinkItem.to_dict`. Returns immutable ``NativeLinkItem`` + records, or raises a precise type/value error before a build is started. + """ if items is None: return () result = [] @@ -1308,14 +1754,17 @@ def _coerce_native_link_items(items: Iterable[NativeLinkItem | dict[str, object] def _link_item_paths(link_items: Iterable[NativeLinkItem]) -> tuple[Path, ...]: + """Extract only filesystem-backed paths from ordered native link items.""" return tuple(Path(item.value) for item in link_items if item.kind in _NATIVE_PATH_LINK_KINDS) def _path_key(path: Path) -> Path: + """Return a non-strict resolved path suitable for equality and lookup.""" return path.resolve(strict=False) def _shared_library_dirs(link_items: Iterable[NativeLinkItem]) -> tuple[Path, ...]: + """Return parent directories of shared-library link items in input order.""" return tuple(Path(item.value).parent for item in link_items if item.kind == "shared_library") @@ -1330,6 +1779,14 @@ def _native_build_inputs( native_library_dirs: Iterable[str | Path] | None, native_include_dirs: Iterable[str | Path] | None, ) -> _NativeBuildInputs: + """Validate and normalize all caller-native inputs for a build request. + + Accepts optional sources, artifacts, libraries, ordered link records, and + search paths, derives shared-library search directories, and returns one + internal input record. It rejects missing files/directories and a request + with no native implementation input before any generated code is compiled. + """ + # Validate independent source, artifact, and explicit-link inputs first. source_paths = _existing_paths(native_fortran_sources, kind="Native Fortran source") source_flags = tuple(str(flag) for flag in (native_fortran_flags or ())) artifact_paths = _existing_paths(native_objects, kind="Native artifact") @@ -1340,6 +1797,8 @@ def _native_build_inputs( ) selected_link_items = explicit_link_items if complete_link_items is None else complete_link_items link_item_paths = _link_item_paths(selected_link_items) + + # Derive search paths after the final ordered link input is known. library_dirs = _unique_paths( ( *_existing_paths(native_library_dirs, kind="Native library", require_directory=True), @@ -1348,6 +1807,8 @@ def _native_build_inputs( ) ) explicit_include_dirs = _existing_paths(native_include_dirs, kind="Native include", require_directory=True) + + # A wrapper has no native implementation without at least one link input. if ( not source_paths and not artifact_paths @@ -1373,6 +1834,12 @@ def _native_build_inputs( def _native_include_dirs(inputs: _NativeBuildInputs, *, output_path: Path) -> tuple[Path, ...]: + """Derive de-duplicated include/module search paths for native compilation. + + Includes the build directory when source compilation produces module files, + caller include directories, and parents of linked artifacts. Returns the + paths without creating directories or changing ``inputs``. + """ module_include_dirs = (output_path,) if inputs.source_paths else () inferred_include_dirs = _unique_paths((*inputs.artifact_paths, *inputs.link_item_paths)) return _unique_paths( @@ -1384,12 +1851,38 @@ def _native_include_dirs(inputs: _NativeBuildInputs, *, output_path: Path) -> tu ) +def _source_object_stems(source_paths: tuple[Path, ...]) -> tuple[str, ...]: + """Return collision-free object stems for an ordered source-path sequence. + + Unique basenames retain their stem. Repeated stems gain a deterministic + one-based suffix in source order so distinct native files never target the + same object path. + """ + totals: dict[str, int] = {} + for source_path in source_paths: + totals[source_path.stem] = totals.get(source_path.stem, 0) + 1 + + seen: dict[str, int] = {} + stems = [] + for source_path in source_paths: + stem = source_path.stem + seen[stem] = seen.get(stem, 0) + 1 + stems.append(stem if totals[stem] == 1 else f"{stem}_{seen[stem]}") + return tuple(stems) + + def _native_source_objects( inputs: _NativeBuildInputs, *, output_path: Path, include_dirs: tuple[Path, ...], ) -> tuple[ObjectFile, ...]: + """Create uncompiled object descriptions for all validated native sources. + + Pairs each source with its collision-free stem and applies the normalized + source flags and include directories. Returns the planned objects in + source order without invoking the compiler. + """ return tuple( _source_compile_object( source_path, @@ -1403,13 +1896,56 @@ def _native_source_objects( def _validate_native_link_paths(plan: NativeBuildPlan) -> None: + """Reject missing filesystem link inputs that this build will not produce. + + Produced object paths are allowed before compilation. Every other path + referenced by an ordered link item must already be a file; the plan itself + is not modified. + """ produced_object_keys = {_path_key(path) for path in plan.produced_objects} for path in _link_item_paths(plan.link_items): if _path_key(path) not in produced_object_keys and not path.is_file(): raise FileNotFoundError(f"Native link item not found: {path}") +def _prepare_native_build_plan( + inputs: _NativeBuildInputs, + *, + output_path: Path, +) -> tuple[tuple[ObjectFile, ...], NativeBuildPlan]: + """Create and validate compiler objects and link inputs for one build.""" + include_dirs = _native_include_dirs(inputs, output_path=output_path) + source_objects = _native_source_objects( + inputs, + output_path=output_path, + include_dirs=include_dirs, + ) + plan = _native_build_plan( + source_paths=inputs.source_paths, + source_objects=source_objects, + artifact_paths=inputs.artifact_paths, + libraries=inputs.libraries, + explicit_link_items=inputs.explicit_link_items, + complete_link_items=inputs.complete_link_items, + library_dirs=inputs.library_dirs, + explicit_include_dirs=inputs.explicit_include_dirs, + include_dirs=include_dirs, + module_dir=output_path if source_objects else None, + ) + _validate_native_link_paths(plan) + return source_objects, plan + + +# Build manifest serialization + + def _manifest_path(path: str | Path, *, base: Path) -> str: + """Encode a path for a portable manifest relative to ``base`` when possible. + + Relative inputs are interpreted from the current working directory before + comparison. Paths on another filesystem fall back to their absolute text; + no files are read or written. + """ value = Path(path) absolute = value if value.is_absolute() else Path.cwd() / value try: @@ -1419,11 +1955,18 @@ def _manifest_path(path: str | Path, *, base: Path) -> str: def _resolve_manifest_path(path: str, *, base: Path) -> Path: + """Turn a manifest path string into an absolute or base-relative path.""" value = Path(path) return value if value.is_absolute() else base / value def _manifest_link_item(item: NativeLinkItem, *, base: Path) -> dict[str, object]: + """Serialize one native link record using manifest-relative file paths. + + File-backed items use a relative ``path`` where possible; named libraries + and raw arguments retain their string values. The input item is not + modified. + """ if item.kind in _NATIVE_PATH_LINK_KINDS: return { "kind": item.kind, @@ -1441,6 +1984,12 @@ def _manifest_link_item(item: NativeLinkItem, *, base: Path) -> dict[str, object def _manifest_native_plan(plan: NativeBuildPlan, *, base: Path) -> dict[str, object]: + """Serialize a complete native build plan for a replayable manifest. + + Converts every filesystem field in ``plan`` to a path relative to ``base`` + when possible and preserves ordered link items and compiler flags. The + returned dictionary is ready for JSON encoding. + """ return { "compilation_units": [ { @@ -1469,6 +2018,7 @@ def _manifest_native_plan(plan: NativeBuildPlan, *, base: Path) -> dict[str, obj def _manifest_native_array_requirements(requirements: NativeArrayBuildRequirements) -> dict[str, object]: + """Serialize native-array bridge requirements into plain manifest values.""" return { "pointer_c_descriptor_interop": requirements.pointer_c_descriptor_interop, "headers": list(requirements.headers), @@ -1503,6 +2053,13 @@ def _pyi_build_manifest( native_array_build_requirements: NativeArrayBuildRequirements, manifest_dir: Path, ) -> dict[str, object]: + """Build the complete in-memory manifest for a semantic ``.pyi`` build. + + Receives the resolved contract bundle, output and compiler choices, and + native plans/array requirements. It returns a schema-versioned plain + dictionary whose paths are relative to ``manifest_dir``; it neither writes + the manifest nor changes the build result. + """ return { "schema_version": _BUILD_MANIFEST_SCHEMA_VERSION, "build_kind": "pyi-wrapper", @@ -1532,6 +2089,12 @@ def _pyi_build_manifest( def _write_build_manifest(path: Path, manifest: dict[str, object]) -> Path: + """Write one deterministic, newline-terminated JSON build manifest. + + The parent directory must already exist. This creates or replaces + ``path`` with sorted, indented JSON and returns the same path for result + attachment. + """ path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") return path @@ -1569,7 +2132,16 @@ def _with_pyi_manifest( return replace(result, manifest=manifest) +# Build manifest validation and replay inputs + + def _load_build_manifest(path: str | Path) -> tuple[Path, dict[str, object]]: + """Read and validate the top-level schema of a saved ``.pyi`` manifest. + + Returns the manifest path and its JSON object only when the file exists, + decodes to an object, and matches this module's schema version and build + kind. Invalid or incompatible files raise input errors before replay. + """ manifest_path = Path(path) if not manifest_path.is_file(): raise FileNotFoundError(f"Wrapper build manifest not found: {manifest_path}") @@ -1584,6 +2156,7 @@ def _load_build_manifest(path: str | Path) -> tuple[Path, dict[str, object]]: def _manifest_section(payload: dict[str, object], key: str) -> dict[str, object]: + """Return a required object section from a validated manifest payload.""" value = payload.get(key) if not isinstance(value, dict): raise ValueError(f"Wrapper build manifest missing object section: {key}") @@ -1591,6 +2164,7 @@ def _manifest_section(payload: dict[str, object], key: str) -> dict[str, object] def _manifest_string_list(section: dict[str, object], key: str) -> tuple[str, ...]: + """Return one optional manifest list field after enforcing string items.""" value = section.get(key, ()) if not isinstance(value, list) or any(not isinstance(item, str) for item in value): raise ValueError(f"Wrapper build manifest field {key!r} must be a list of strings") @@ -1598,6 +2172,7 @@ def _manifest_string_list(section: dict[str, object], key: str) -> tuple[str, .. def _manifest_bool(section: dict[str, object], key: str, *, default: bool = False) -> bool: + """Return one manifest boolean field or its explicit default after validation.""" value = section.get(key, default) if not isinstance(value, bool): raise ValueError(f"Wrapper build manifest field {key!r} must be a boolean") @@ -1605,6 +2180,7 @@ def _manifest_bool(section: dict[str, object], key: str, *, default: bool = Fals def _manifest_string(section: dict[str, object], key: str) -> str: + """Return a required non-empty manifest string field or raise ``ValueError``.""" value = section.get(key) if not isinstance(value, str) or not value: raise ValueError(f"Wrapper build manifest field {key!r} must be a non-empty string") @@ -1612,10 +2188,17 @@ def _manifest_string(section: dict[str, object], key: str) -> str: def _manifest_path_list(section: dict[str, object], key: str, *, base: Path) -> tuple[Path, ...]: + """Resolve an optional manifest string-list field into paths from ``base``.""" return tuple(_resolve_manifest_path(item, base=base) for item in _manifest_string_list(section, key)) def _native_link_item_from_manifest(item: object, *, base: Path) -> NativeLinkItem: + """Validate and reconstruct one ordered native link record from manifest JSON. + + File-backed item paths are resolved from ``base``; library names and raw + arguments remain strings. Unsupported shapes and kinds raise ``ValueError`` + instead of producing a partially replayable build. + """ if not isinstance(item, dict): raise ValueError("Wrapper build manifest link items must be objects") kind = item.get("kind") @@ -1640,6 +2223,7 @@ def _native_link_item_from_manifest(item: object, *, base: Path) -> NativeLinkIt def _manifest_link_items(section: dict[str, object], *, base: Path) -> tuple[NativeLinkItem, ...]: + """Reconstruct the ordered native link-item list stored in a manifest.""" value = section.get("link_items", ()) if not isinstance(value, list): raise ValueError("Wrapper build manifest field 'link_items' must be a list") @@ -1647,6 +2231,12 @@ def _manifest_link_items(section: dict[str, object], *, base: Path) -> tuple[Nat def _manifest_compilation_sources(section: dict[str, object], *, base: Path) -> tuple[Path, ...]: + """Return Fortran source paths recorded by a manifest's compilation units. + + Validates the unit-list shape and rejects source languages this replay path + cannot rebuild. Paths are resolved relative to ``base`` and returned in + recorded order without checking their current existence. + """ value = section.get("compilation_units", ()) if not isinstance(value, list): raise ValueError("Wrapper build manifest field 'compilation_units' must be a list") @@ -1660,21 +2250,16 @@ def _manifest_compilation_sources(section: dict[str, object], *, base: Path) -> return tuple(sources) -def _source_object_stems(source_paths: tuple[Path, ...]) -> tuple[str, ...]: - totals: dict[str, int] = {} - for source_path in source_paths: - totals[source_path.stem] = totals.get(source_path.stem, 0) + 1 - - seen: dict[str, int] = {} - stems = [] - for source_path in source_paths: - stem = source_path.stem - seen[stem] = seen.get(stem, 0) + 1 - stems.append(stem if totals[stem] == 1 else f"{stem}_{seen[stem]}") - return tuple(stems) +# Wrapper module assembly def _merge_wrapper_modules(modules: list[SemanticModule], *, name: str | None = None) -> SemanticModule: + """Flatten semantic source modules into the one extension-facing module. + + Concatenates every declaration category while preserving list order and + derives combined metadata and the origin from the first module. An empty + input cannot produce a wrapper and raises ``ValueError``. + """ if not modules: raise ValueError("wrapper build found no Fortran modules or standalone procedures") @@ -1691,6 +2276,12 @@ def _merge_wrapper_modules(modules: list[SemanticModule], *, name: str | None = def _wrapper_module_metadata(modules: list[SemanticModule]) -> dict[str, object]: + """Collect metadata needed by one merged wrapper module. + + Records native module scopes and propagates export/contract readiness flags + when any input module has them. It returns a fresh dictionary and does not + change the input semantic modules. + """ metadata: dict[str, object] = {"wrapper_native_modules": _wrapper_native_modules(modules)} if any(module.metadata.get(PYTHON_EXPORTS_PREPARED_METADATA) for module in modules): metadata[PYTHON_EXPORTS_PREPARED_METADATA] = True @@ -1701,6 +2292,7 @@ def _wrapper_module_metadata(modules: list[SemanticModule]) -> dict[str, object] def _wrapper_native_modules(modules: list[SemanticModule]) -> list[str]: + """Return unique native module names that require a generated native scope.""" return list( dict.fromkeys( str(module.origin.native_name or module.name) @@ -1711,13 +2303,22 @@ def _wrapper_native_modules(modules: list[SemanticModule]) -> list[str]: def _module_requires_native_scope(module: SemanticModule) -> bool: + """Return whether a module needs native-scope access in generated wrappers. + + Variables and classes always need a scope. Procedures need one only when + their native origin declares it; the module is inspected but not changed. + """ if module.variables or module.classes: return True functions = [*module.functions, *(procedure for item in module.overload_sets for procedure in item.procedures)] return any(function.origin.native_scope is not None for function in functions) +# Recorded compiler commands and Makefile output + + def _command_output(command: tuple[str, ...]) -> str | None: + """Return the argument following ``-o`` in one recorded compiler command.""" try: return command[command.index("-o") + 1] except (ValueError, IndexError): @@ -1725,6 +2326,7 @@ def _command_output(command: tuple[str, ...]) -> str | None: def _command_source(command: tuple[str, ...]) -> str | None: + """Return the first recognized native source argument in a compiler command.""" for part in command: if Path(part).suffix.lower() in _FORTRAN_SOURCE_SUFFIXES | _C_SOURCE_SUFFIXES: return part @@ -1732,6 +2334,7 @@ def _command_source(command: tuple[str, ...]) -> str | None: def _command_language(command: tuple[str, ...]) -> str | None: + """Infer ``fortran`` or ``c`` from a command's detected source suffix.""" source = _command_source(command) if source is None: return None @@ -1739,19 +2342,28 @@ def _command_language(command: tuple[str, ...]) -> str | None: def _absolute_command_path(path: str | Path, working_directory: Path) -> Path: + """Resolve a recorded command path against its original working directory.""" result = Path(path) return result if result.is_absolute() else working_directory / result def _make_target(path: Path) -> str: + """Escape a filesystem path for safe use as a GNU Make target or dependency.""" return str(path).replace("$", "$$").replace("#", r"\#").replace(" ", r"\ ") def _make_shell_literal(text: str) -> str: + """Escape dollar signs so Make passes a recorded literal through to the shell.""" return text.replace("$", "$$") def _make_recipe(command: tuple[str, ...], working_directory: Path) -> str: + """Convert one recorded compiler command into an overridable Make recipe. + + Selects the Fortran, C, or shared-linker variable from the command and + separates compiler-fixed arguments from caller-overridable flag variables. + The returned tab-prefixed recipe runs from ``working_directory``. + """ language = _command_language(command) if "-shared" in command: compiler_var, flags_var = "PRIK_LD", "PRIK_LDFLAGS" @@ -1768,6 +2380,11 @@ def _make_recipe(command: tuple[str, ...], working_directory: Path) -> str: def _compiler_executable(commands: tuple[tuple[str, ...], ...], *, language: str | None, shared: bool) -> str: + """Find the recorded compiler executable for one Makefile variable. + + Searches commands by source language or shared-link status and returns a + conservative GNU compiler default when no matching command was recorded. + """ for command in commands: if ("-shared" in command) == shared and (shared or _command_language(command) == language): return command[0] @@ -1783,6 +2400,7 @@ def _write_build_makefile( extra_dependencies: Iterable[Path] = (), ) -> Path: """Write a GNU Make build from recorded compiler commands.""" + # Separate recorded compile and link commands before constructing rules. compile_commands = tuple(command for command in commands if "-c" in command and _command_output(command)) link_command = next((command for command in reversed(commands) if "-shared" in command), None) if link_command is None: @@ -1795,6 +2413,8 @@ def _write_build_makefile( _absolute_command_path(_command_output(command), working_directory) for command in compile_commands ) makefile_path = path.resolve() + + # Preserve compiler selection while leaving caller-overridable flags empty. lines = [ "# Generated by prik. Edit variables or override them on the make command line.", "# User Fortran sources are conservatively chained in supplied order.", @@ -1811,6 +2431,7 @@ def _write_build_makefile( link_output = _absolute_command_path(_command_output(link_command), working_directory) lines.extend([".PHONY: all rebuild clean", f"all: {_make_target(link_output)}", ""]) + # User sources remain ordered; generated objects depend on all native objects. previous_user_output = None for command, output in zip(compile_commands, compile_outputs, strict=True): source = _absolute_command_path(_command_source(command), working_directory) @@ -1832,6 +2453,8 @@ def _write_build_makefile( all_link_dependencies = tuple(dict.fromkeys((*compile_outputs, *extra_dependencies))) object_dependencies = " ".join(_make_target(output) for output in all_link_dependencies) + + # Link, rebuild, and cleanup rules share the recorded artifact paths. lines.extend( [ f"{_make_target(link_output)}: {object_dependencies}", @@ -1850,7 +2473,11 @@ def _write_build_makefile( return path +# Fortran type probing + + def _can_probe_fortran_types(preprocessing: PreprocessingConfig) -> bool: + """Return whether the preprocessing configuration can invoke a compiler probe.""" return preprocessing.uses_compiler and bool(preprocessing.compiler) @@ -1877,6 +2504,12 @@ def _wrap_compile_time_values( cache_dir: str | Path | None = None, refresh: bool = False, ) -> dict[str, int] | None: + """Measure only the compile-time values required by a parsed source project. + + Returns ``None`` when no report/probe is possible or no values are needed. + Otherwise it delegates the parsed requirements and optional probe controls + to the type evaluator, which may read or refresh its cache. + """ if report is None and not _can_probe_fortran_types(preprocessing): return None requirements = collect_semantic_compile_time_requirements(parsed) @@ -1902,6 +2535,12 @@ def _wrap_type_facts( cache_dir: str | Path | None = None, refresh: bool = False, ) -> dict[tuple[str, str | None], dict[str, object]] | None: + """Measure native type-storage facts required by a parsed source project. + + Uses prior ``compile_time_values`` to derive requirements. Returns + ``None`` when probing is unavailable or unnecessary; otherwise delegates to + the type-fact evaluator, which may execute or reuse a compiler probe. + """ if report is None and not _can_probe_fortran_types(preprocessing): return None requirements = collect_fortran_type_storage_requirements(parsed, compile_time_values=compile_time_values) @@ -1917,6 +2556,72 @@ def _wrap_type_facts( ) +def _bundle_output_name(bundle: _PyiContractBundle) -> str: + """Derive a default extension name from a file or package-entry contract. + + Package ``__init__.pyi`` entries use their parent directory name; ordinary + contract files use their stem. The bundle is read only and name validation + happens separately. + """ + if bundle.entry.name == "__init__.pyi": + return bundle.entry.resolve().parent.name + return bundle.entry.stem + + +# Source-to-semantic preparation + + +def _fortran_wrapper_module( + source_paths: tuple[Path, ...], + *, + preprocessing: PreprocessingConfig, + type_probe_preprocessing: PreprocessingConfig, + output_name: str | None, + fortran_type_report, + fortran_type_probe_runner: list[str] | None, + fortran_type_probe_cache_dir: str | Path | None, + refresh_fortran_type_probe: bool, +) -> tuple[object, SemanticModule]: + """Parse Fortran sources, resolve type facts, and form one wrapper module.""" + # Preprocess and parse the complete source project. + preprocessed_sources = { + str(source_path): _fortran_source_for_pipeline(source_path, preprocessing) for source_path in source_paths + } + parsed = parse_fortran_project(preprocessed_sources) + + # Measure compiler-dependent values before building semantic IR. + compile_time_values = _wrap_compile_time_values( + parsed, + type_probe_preprocessing, + report=fortran_type_report, + runner=fortran_type_probe_runner, + cache_dir=fortran_type_probe_cache_dir, + refresh=refresh_fortran_type_probe, + ) + type_facts = _wrap_type_facts( + parsed, + type_probe_preprocessing, + compile_time_values=compile_time_values, + report=fortran_type_report, + runner=fortran_type_probe_runner, + cache_dir=fortran_type_probe_cache_dir, + refresh=refresh_fortran_type_probe, + ) + + # Preserve source export paths while flattening the wrapper-facing module. + modules = fortran_project_to_semantic_modules( + parsed, + compile_time_values=compile_time_values, + type_facts=type_facts, + ) + _apply_source_python_exports(modules) + module_name = _validated_wrapper_module_name(output_name, source_paths[0].stem) + return parsed, _merge_wrapper_modules(modules, name=module_name) + + +# Public build entry points + + def build_fortran_extension( sources: str | Path | Iterable[str | Path], *, @@ -1945,19 +2650,88 @@ def build_fortran_extension( wrapper_c_flags: Iterable[str] | None = None, _on_total_build_time: Callable[[float], None] | None = None, ) -> WrapperBuildResult: - """Build one extension, or generate its sources or Makefile, from ordered sources.""" + """Build a Python extension from one or more Fortran source files. - if makefile and generate_sources: - raise ValueError("source-only and Makefile generation are mutually exclusive") - generation_only = makefile or generate_sources - compile_jobs = _normalize_compile_jobs(jobs) - if generation_only and verbose: - raise ValueError("source/Makefile generation and verbose direct compilation are separate modes") + This is the source-first public build API. Supply a file, an ordered + iterable of files, or a directory of Fortran sources, along with the native + implementation that exports the wrapped procedures. On success, import + the built extension from ``result.shared_library.parent`` or inspect the + generated sources and native link plan in the returned result. + + For the usual direct build, use the same sources for parsing and native + compilation:: + + result = build_fortran_extension( + "solver.f90", output_dir="build/solver", output_name="solver" + ) + # result.shared_library is the importable extension artifact. + + Parameters + ---------- + sources + One supported Fortran source path, an ordered iterable of paths, or a + directory to discover recursively. Source order is preserved and is + used for compilation fallback ordering. + output_dir, output_name + Build directory and optional importable Python module name. Omit + ``output_dir`` to use ``__prik__`` in the current directory; omit + ``output_name`` to derive it from the first source. + preprocessing + Optional source preprocessing configuration. The default uses + compiler-backed ``gfortran`` preprocessing. + strict_wrapper_names + Reject generated Python names that cannot be represented without a + strict naming decision. + fortran_type_report, fortran_type_probe_runner, + fortran_type_probe_cache_dir, refresh_fortran_type_probe + Optional controls for compiler-probed Fortran type facts used while + constructing semantic IR. + compile_input_sources + Compile ``sources`` as native implementation inputs. Set false only + when their implementation is supplied separately as objects, libraries, + link items, or ``native_fortran_sources``. + native_fortran_sources, native_fortran_flags + Additional implementation sources and their compiler flags. + native_objects, native_libraries, native_link_items, + native_library_dirs, native_include_dirs + Existing artifacts, ``-l`` names, ordered linker records, and search + paths for the native implementation. Use ``native_link_items`` when + linker order is significant. + makefile, generate_sources + Choose a non-executing output mode. ``makefile=True`` writes a + replayable ``Makefile.prik``; ``generate_sources=True`` writes wrapper + artifacts only. They cannot be combined with each other or ``verbose``. + jobs + Positive maximum number of simultaneous compiler processes. ``None`` + uses the available processor count. + verbose, wrapper_compiler_debug, wrapper_fortran_flags, wrapper_c_flags + Build progress output, generated-wrapper debug mode, and additional + flags for generated bridge and binding compilation. + + Returns + ------- + WrapperBuildResult + Paths, generated files, compilation mode, and the complete native build + plan. ``compiled`` is false in source-only and Makefile modes. + + Raises + ------ + ValueError, FileNotFoundError + If inputs, module names, modes, native link items, or source paths are + invalid. + """ + + generation_only, compile_jobs = _resolve_build_mode( + makefile=makefile, + generate_sources=generate_sources, + jobs=jobs, + verbose=verbose, + ) build_started = time.perf_counter() - source_paths = _source_paths(sources) - primary_source = source_paths[0] + # 1. Collect the source and native implementation inputs. + source_paths = _source_paths(sources) output_path, shared_library_output_path = _wrapper_output_paths(output_dir) output_path.mkdir(parents=True, exist_ok=True) preprocessing = preprocessing or _default_preprocessing_config() @@ -1976,43 +2750,26 @@ def build_fortran_extension( ) type_probe_preprocessing = _type_probe_preprocessing(preprocessing, native_inputs.source_flags) - preprocessed_sources = { - str(source_path): _fortran_source_for_pipeline(source_path, preprocessing) for source_path in source_paths - } - parsed = parse_fortran_project(preprocessed_sources) - compile_time_values = _wrap_compile_time_values( - parsed, - type_probe_preprocessing, - report=fortran_type_report, - runner=fortran_type_probe_runner, - cache_dir=fortran_type_probe_cache_dir, - refresh=refresh_fortran_type_probe, + # 2. Parse source, resolve target facts, and assemble semantic IR. + parsed, module = _fortran_wrapper_module( + source_paths, + preprocessing=preprocessing, + type_probe_preprocessing=type_probe_preprocessing, + output_name=output_name, + fortran_type_report=fortran_type_report, + fortran_type_probe_runner=fortran_type_probe_runner, + fortran_type_probe_cache_dir=fortran_type_probe_cache_dir, + refresh_fortran_type_probe=refresh_fortran_type_probe, ) - type_facts = _wrap_type_facts( - parsed, - type_probe_preprocessing, - compile_time_values=compile_time_values, - report=fortran_type_report, - runner=fortran_type_probe_runner, - cache_dir=fortran_type_probe_cache_dir, - refresh=refresh_fortran_type_probe, - ) - modules = fortran_project_to_semantic_modules( - parsed, - compile_time_values=compile_time_values, - type_facts=type_facts, - ) - _apply_source_python_exports(modules) - requested_name = output_name or primary_source.stem - if not requested_name.isidentifier(): - raise ValueError(f"Output name must be a valid Python identifier: {requested_name!r}") - module = _merge_wrapper_modules(modules, name=requested_name) + + # 3. Complete wrapper policy and render the canonical artifacts. rendered_wrapper_plan = _generated_wrapper_plan_artifacts( module, strict_wrapper_names=strict_wrapper_names, verbose=verbose, ) + # 4. Prepare native compilation, dependency batches, and link inputs. wrapper_fortran_flags = _compiler_flags(wrapper_fortran_flags) wrapper_c_flags = _compiler_flags(wrapper_c_flags) compiler = _new_compiler( @@ -2020,27 +2777,10 @@ def build_fortran_extension( debug=wrapper_compiler_debug, input_compiler=preprocessing.compiler if preprocessing.uses_compiler else None, ) - include_dirs = _native_include_dirs(native_inputs, output_path=output_path) - native_source_objects = _native_source_objects( - native_inputs, - output_path=output_path, - include_dirs=include_dirs, - ) - native_build_plan = _native_build_plan( - source_paths=native_inputs.source_paths, - source_objects=native_source_objects, - artifact_paths=native_inputs.artifact_paths, - libraries=native_inputs.libraries, - explicit_link_items=native_inputs.explicit_link_items, - complete_link_items=None, - library_dirs=native_inputs.library_dirs, - explicit_include_dirs=native_inputs.explicit_include_dirs, - include_dirs=include_dirs, - module_dir=output_path if native_source_objects else None, - ) - _validate_native_link_paths(native_build_plan) + native_source_objects, native_build_plan = _prepare_native_build_plan(native_inputs, output_path=output_path) native_compile_batches = _project_compile_batches(parsed, native_source_objects) + # 5. Build the extension, or retain the generated source/Makefile plan. result = _build_rendered_wrapper_extension( rendered_wrapper_plan, output_dir=output_path, @@ -2056,15 +2796,14 @@ def build_fortran_extension( compile_jobs=1 if generation_only else compile_jobs, verbose=verbose, ) - if makefile: - result = _attach_build_makefile( - result, - compiler=compiler, - source_objects=native_source_objects, - extra_dependencies=_link_item_paths(native_build_plan.link_items), - ) - elif generate_sources: - result = replace(result, compiled=False) + result = _finalize_build_mode( + result, + makefile=makefile, + generate_sources=generate_sources, + compiler=compiler, + source_objects=native_source_objects, + extra_dependencies=_link_item_paths(native_build_plan.link_items), + ) _report_total_build_time( verbose, time.perf_counter() - build_started, @@ -2097,16 +2836,74 @@ def build_pyi_extension( wrapper_c_flags: Iterable[str] | None = None, _on_total_build_time: Callable[[float], None] | None = None, ) -> WrapperBuildResult: - """Build one extension, or generate its sources or Makefile, from one `.pyi` entry.""" + """Build a Python extension from an editable semantic ``.pyi`` contract. - if makefile and generate_sources: - raise ValueError("source-only and Makefile generation are mutually exclusive") - generation_only = makefile or generate_sources - compile_jobs = _normalize_compile_jobs(jobs) - if generation_only and verbose: - raise ValueError("source/Makefile generation and verbose direct compilation are separate modes") + Use this API when the public Python surface is defined by a semantic + contract and native code already exists separately. The entry contract may + import relative ``.pyi`` modules; all reachable native declarations are + validated, rendered, and linked into one extension. + + For example, compile an existing native implementation and pass its object + file to the contract build:: + + result = build_pyi_extension( + "api.pyi", native_objects=["build/api.o"], output_dir="build/api" + ) + + Parameters + ---------- + contract + Existing semantic ``.pyi`` entry file. Its relative-import graph is + loaded as one contract bundle. + input_compiler + Fortran compiler executable used for generated bridge code and optional + native source compilation. + native_fortran_sources, native_fortran_flags + Existing implementation source paths to compile and their flags. + native_objects, native_libraries, native_link_items, + native_library_dirs, native_include_dirs + Existing artifacts, ``-l`` names, ordered linker records, and search + paths. Use ``native_link_items`` to append ordered inputs, or + ``complete_native_link_items`` to supply the full ordered link plan. + output_name, output_dir + Optional Python extension name and build directory. The default name + comes from the contract file or package entry. + strict_wrapper_names + Enforce strict generated Python-name validation during policy + completion. + makefile, generate_sources + Select non-executing output: a replayable ``Makefile.prik`` or generated + sources only. These modes are mutually exclusive and cannot be verbose. + jobs + Positive compiler-process limit; omit to use available processors. + verbose, wrapper_compiler_debug, wrapper_fortran_flags, wrapper_c_flags + Progress, generated-wrapper debug mode, and generated bridge/binding + compiler flags. + + Returns + ------- + WrapperBuildResult + Generated artifact paths, a native build plan, and an in-memory build + manifest. Makefile mode also persists that manifest and records its + path in ``build_manifest``. + + Raises + ------ + ValueError, FileNotFoundError + If the contract graph, native inputs, requested mode, or link plan is + invalid. + """ + + generation_only, compile_jobs = _resolve_build_mode( + makefile=makefile, + generate_sources=generate_sources, + jobs=jobs, + verbose=verbose, + ) build_started = time.perf_counter() + + # 1. Load the contract graph and collect native implementation inputs. entry = _pyi_entry_path(contract) bundle = _pyi_contract_bundle(entry) native_inputs = _native_build_inputs( @@ -2125,42 +2922,26 @@ def build_pyi_extension( wrapper_fortran_flags = _compiler_flags(wrapper_fortran_flags) wrapper_c_flags = _compiler_flags(wrapper_c_flags) + # 2. Assemble semantic IR, complete policy, and render wrapper artifacts. modules = list(bundle.modules) - requested_name = output_name or _bundle_output_name(bundle) - if not requested_name.isidentifier(): - raise ValueError(f"Output name must be a valid Python identifier: {requested_name!r}") - module = _merge_wrapper_modules(modules, name=requested_name) + module_name = _validated_wrapper_module_name(output_name, _bundle_output_name(bundle)) + module = _merge_wrapper_modules(modules, name=module_name) rendered_wrapper_plan = _generated_wrapper_plan_artifacts( module, strict_wrapper_names=strict_wrapper_names, verbose=verbose, ) - include_dirs = _native_include_dirs(native_inputs, output_path=output_path) - native_source_objects = _native_source_objects( - native_inputs, - output_path=output_path, - include_dirs=include_dirs, - ) - native_build_plan = _native_build_plan( - source_paths=native_inputs.source_paths, - source_objects=native_source_objects, - artifact_paths=native_inputs.artifact_paths, - libraries=native_inputs.libraries, - explicit_link_items=native_inputs.explicit_link_items, - complete_link_items=native_inputs.complete_link_items, - library_dirs=native_inputs.library_dirs, - explicit_include_dirs=native_inputs.explicit_include_dirs, - include_dirs=include_dirs, - module_dir=output_path if native_source_objects else None, - ) - _validate_native_link_paths(native_build_plan) + # 3. Prepare native compilation and link inputs before selecting the compiler. + native_source_objects, native_build_plan = _prepare_native_build_plan(native_inputs, output_path=output_path) compiler = _new_compiler( execute_commands=not generation_only, debug=wrapper_compiler_debug, input_compiler=input_compiler, ) native_array_build_requirements = native_array_handle_build_requirements(module) + + # 4. Build the extension and attach its replayable manifest data. result = _build_rendered_wrapper_extension( rendered_wrapper_plan, output_dir=output_path, @@ -2188,22 +2969,26 @@ def build_pyi_extension( wrapper_c_flags=wrapper_c_flags, native_array_build_requirements=native_array_build_requirements, ) + + # 5. Optionally persist the manifest and Makefile instead of a direct build. + build_manifest = None + makefile_dependencies: tuple[Path, ...] = () if makefile: build_manifest = _write_build_manifest(output_path / _BUILD_MANIFEST_NAME, result.manifest) - dependencies = ( + makefile_dependencies = ( *bundle.paths, *_link_item_paths(native_build_plan.link_items), build_manifest, ) - result = _attach_build_makefile( - result, - compiler=compiler, - source_objects=native_source_objects, - extra_dependencies=dependencies, - build_manifest=build_manifest, - ) - elif generate_sources: - result = replace(result, compiled=False) + result = _finalize_build_mode( + result, + makefile=makefile, + generate_sources=generate_sources, + compiler=compiler, + source_objects=native_source_objects, + extra_dependencies=makefile_dependencies, + build_manifest=build_manifest, + ) _report_total_build_time( verbose, time.perf_counter() - build_started, @@ -2224,9 +3009,40 @@ def build_pyi_extension_from_manifest( verbose: bool | int = False, _on_total_build_time: Callable[[float], None] | None = None, ) -> WrapperBuildResult: - """Replay a saved semantic `.pyi` wrapper build manifest.""" + """Replay a saved semantic ``.pyi`` wrapper build manifest. + + First create a manifest with ``build_pyi_extension(..., makefile=True)``. + This entrypoint restores its contract, compiler choices, native compilation + sources, and ordered link plan, then delegates to ``build_pyi_extension``. + The current contract import graph must still match the recorded graph. + + Parameters + ---------- + manifest + Existing ``prik-build.json`` produced by a semantic ``.pyi`` build. + output_name, input_compiler, include_dirs + Optional replay overrides for the extension name, compiler executable, + and additional native include directories. All other build choices are + restored from the manifest. + makefile, generate_sources, jobs, verbose + Output mode and compilation controls with the same meanings as + :func:`build_pyi_extension`. + + Returns + ------- + WrapperBuildResult + The direct-build, source-only, or Makefile result produced by replay. + + Raises + ------ + FileNotFoundError, ValueError + If the manifest is absent or incompatible, recorded inputs are invalid, + or the present contract graph no longer matches the saved build. + """ build_started = time.perf_counter() + + # 1. Load the recorded build inputs and validate required sections. manifest_path, payload = _load_build_manifest(manifest) base = manifest_path.parent native_section = _manifest_section(payload, "native_build_plan") @@ -2248,6 +3064,7 @@ def build_pyi_extension_from_manifest( if requested_name is not None and not isinstance(requested_name, str): raise ValueError("Wrapper build manifest extension.requested_name must be a string or null") + # 2. Restore native include paths and compiler selection from the manifest. manifest_module_dirs = _manifest_path_list(native_section, "module_dirs", base=base) native_include_dirs = _unique_paths( ( @@ -2258,6 +3075,8 @@ def build_pyi_extension_from_manifest( selected_input_compiler = input_compiler if selected_input_compiler is None: selected_input_compiler = _manifest_string(compiler_section, "input_executable") + + # 3. Delegate execution to the regular `.pyi` build path. result = build_pyi_extension( _resolve_manifest_path(entry_contract, base=base), input_compiler=selected_input_compiler, @@ -2278,6 +3097,8 @@ def build_pyi_extension_from_manifest( complete_native_link_items=_manifest_link_items(native_section, base=base), _on_total_build_time=lambda _elapsed: None, ) + + # 4. Ensure the current contract graph still matches the recorded build. recorded_contracts = tuple( _resolve_manifest_path(path, base=base) for path in _manifest_string_list(payload, "contract_paths") ) @@ -2291,7 +3112,30 @@ def build_pyi_extension_from_manifest( return result -def _bundle_output_name(bundle: _PyiContractBundle) -> str: - if bundle.entry.name == "__init__.pyi": - return bundle.entry.resolve().parent.name - return bundle.entry.stem +# Direct-execution example + + +if __name__ == "__main__": + from tempfile import TemporaryDirectory + + import numpy as np + + source_text = """\ +real(8) function scale(value, factor) result(output) + real(8), intent(in) :: value + real(8), intent(in) :: factor + output = value * factor +end function scale +""" + with TemporaryDirectory() as temporary_dir: + temporary_path = Path(temporary_dir) + source_path = temporary_path / "scale.f90" + source_path.write_text(source_text, encoding="utf-8") + build = build_fortran_extension( + source_path, + output_dir=temporary_path / "build", + output_name="build_example", + ) + module = build.import_module() + value = module.scale(np.float64(3.0), np.float64(2.5)) + print(f"scale(3.0, 2.5) = {value}") diff --git a/pyproject.toml b/pyproject.toml index d5c0621cd..f88bfd518 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [build-system] -requires = ["setuptools>=77.0.3", "wheel"] +requires = ["setuptools>=77.0.3"] build-backend = "setuptools.build_meta" [project] name = "prik" version = "0.1.1" -description = "Fortran-to-Python native extensions with editable .pyi contracts" +description = "Fortran-to-Python binding generator with editable .pyi contracts" readme = "README.md" authors = [ { name = "Said Hadjout" , email = "saidaissa.hadjout@gmail.com"}, @@ -19,6 +19,10 @@ keywords = [ "cpython-extension", "numpy", "wrapper-generator", + "fortran-python", + "python-bindings", + "wrapper", + "f2py-alternative", "scientific-computing", ] classifiers = [ diff --git a/tests/fortran/building_shared_library/end_to_end/test_source_build_modes.py b/tests/fortran/building_shared_library/end_to_end/test_source_build_modes.py index fa96b35de..408c4cb3c 100644 --- a/tests/fortran/building_shared_library/end_to_end/test_source_build_modes.py +++ b/tests/fortran/building_shared_library/end_to_end/test_source_build_modes.py @@ -21,9 +21,22 @@ SCALE_SOURCE = NATIVE_FIXTURES / "scale.f90" SCALAR_SOURCE = SCALE_SOURCE HOME_POINTS_SOURCE = NATIVE_FIXTURES / "home_points.f90" +BUILD_MODULE = Path(__file__).resolve().parents[4] / "prik" / "pipeline" / "build.py" pytestmark = pytest.mark.fortran_end_to_end +def test_build_module_direct_execution_runs_the_public_api_example(): + result = subprocess.run( + [sys.executable, str(BUILD_MODULE)], + capture_output=True, + text=True, + check=True, + cwd=BUILD_MODULE.parents[2], + ) + + assert result.stdout == "scale(3.0, 2.5) = 7.5\n" + + def test_verbose_mode_prints_full_direct_build_commands(tmp_path: Path): source = tmp_path / "verbose_api.f90" shutil.copyfile(VERBOSE_SOURCE, source) diff --git a/tests/fortran/building_shared_library/pipeline/test_pyi_build_modes.py b/tests/fortran/building_shared_library/pipeline/test_pyi_build_modes.py index d91a443be..ab357bec1 100644 --- a/tests/fortran/building_shared_library/pipeline/test_pyi_build_modes.py +++ b/tests/fortran/building_shared_library/pipeline/test_pyi_build_modes.py @@ -12,7 +12,7 @@ import pytest from prik import build_pyi_extension -from prik.pipeline.build import build_fortran_extension +from prik.pipeline.build import WrapperBuildResult, build_fortran_extension from tests.fortran._support.pyi_fixtures import assert_generated_pyi_package_matches_fixture FEATURE_ROOT = Path(__file__).resolve().parents[1] @@ -111,6 +111,36 @@ def _assert_scale_runtime_contract(module) -> None: assert module.scale(np.float64(2.0), np.float64(4.0)) == np.float64(8.0) +def test_wrapper_build_result_import_module_loads_and_caches_a_built_extension(tmp_path: Path): + result = build_fortran_extension(SOURCE, output_dir=tmp_path / "source_build") + + sys.modules.pop(result.module_name, None) + try: + module = result.import_module() + assert module.__file__ == str(result.shared_library) + native_module = _sole_native_module(module) + assert native_module.scale(np.float64(3.0), np.float64(2.5)) == np.float64(7.5) + assert result.import_module() is module + finally: + sys.modules.pop(result.module_name, None) + + +def test_wrapper_build_result_import_module_requires_a_built_artifact(tmp_path: Path): + result = WrapperBuildResult( + sources=(), + module_name="missing_extension", + output_dir=tmp_path, + shared_library=tmp_path / "missing_extension.so", + build_makefile=None, + compiled=False, + generated_sources=(), + generated_files=(), + ) + + with pytest.raises(FileNotFoundError, match=r"Built extension not found: .+missing_extension\.so"): + result.import_module() + + @pytest.fixture def scale_runtime_module(pyi_parity_build_mode: str, tmp_path: Path): if pyi_parity_build_mode == "source": diff --git a/tests/shared/docs/test_structure.py b/tests/shared/docs/test_structure.py index 14595310d..55b13f21c 100644 --- a/tests/shared/docs/test_structure.py +++ b/tests/shared/docs/test_structure.py @@ -1056,8 +1056,6 @@ def test_readme_opening_uses_the_homepage_message_and_showcase() -> None: readme = _visible_documentation_source(ROOT / "README.md") readme_opening = readme.split("## Installation & Quick Start", maxsplit=1)[0] shared_content = ( - "**Turn Fortran into natural Python APIs.**", - "Build clean, importable native extensions from supported Fortran without\nwriting low-level binding code.", "python3 -m prik points.f90 --out geometry", "", "import geometry.points as points",