diff --git a/README.md b/README.md index 298edff..68c163a 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,31 @@ r = Rectangle(4, 5) directories to your build's include paths, exactly as before. Free functions are not covered — a free function using a caster type still needs the header added manually. See `examples/cells/dynamic/config.yaml`. +- A wrapped signature may use another **project type** whose definition the + class's own header only forward-declares — e.g. a `MeshFactory` whose + `generateMesh()` returns a `PottsMesh` it never `#include`s. The wrapper then + needs that type's header (unless you use `common_include_file`, which pulls in + everything). Rather than listing it by hand under `source_includes`, set + `auto_includes: True` (at package, module or class level; it inherits down the + info tree, and is off by default): + + ```yaml + modules: + - name: mymod + classes: + - name: MeshFactory + auto_includes: True # resolves PottsMesh.hpp from the signature + ``` + + cppwg then scans each such class's wrapped method/constructor signatures, and + for every **project type** it finds (a class defined under the module + `source_locations`) adds that class's header to the wrapper. Only project types + are resolved — library types (`std::`, boost, PETSc, VTK, …) are left alone — + and a type name that is defined in more than one header is treated as ambiguous + and left for a manual `source_includes`. It is a no-op with + `common_include_file`, and does not cover types that appear only in + hand-written `custom_generator` code (cppwg cannot see those). See the + `MeshFactory` class in `examples/cells/dynamic/config.yaml`. - A wrapped class can inherit from a base class wrapped in a **different module**, as long as that base is registered somewhere that gets imported first. Opt in per module with `imports`, which lists the Python modules to diff --git a/cppwg/generators.py b/cppwg/generators.py index 74a7252..ea08cfc 100644 --- a/cppwg/generators.py +++ b/cppwg/generators.py @@ -461,6 +461,12 @@ def generate(self) -> None: # by those steps; sorting earlier would use incomplete inheritance info. self.package_info.sort_classes() + # For classes that opt into auto_includes, resolve the headers of the + # project types their wrapped signatures use, so those headers are added + # to the class wrappers automatically. Runs after the wrapped set is final + # so the resolved headers reflect exactly what will be wrapped. + self.package_info.resolve_auto_includes() + # Re-write the header collection so it reflects the final wrapped set: # base-class-discovered classes are added and pruned instantiations are # removed (the first write, before parsing, could not know either). The diff --git a/cppwg/info/base_info.py b/cppwg/info/base_info.py index 44a6771..2ebaa7c 100644 --- a/cppwg/info/base_info.py +++ b/cppwg/info/base_info.py @@ -27,6 +27,16 @@ class BaseInfo(ABC): Exclude any method, constructor or free function with an argument of one of these types. Patterns match a type as a whole token (so `Node` does not match `AbstractNode`). + auto_includes : bool | None + Automatically add `#include`s for the project types a class's wrapped + method/constructor signatures use, when the class's own header only + forward-declares them (so the wrapper still compiles without listing them + by hand under `source_includes`). Only project types - classes defined + under the module `source_locations` - are resolved; library types are + left alone. None (the default) means inherit from further up the info + tree, where it is treated as off (opt-in); set it to True or False at any + level (package, module or class). Has no effect with + `common_include_file` (the common header already includes everything). calldef_excludes : list[str] Deprecated: use arg_type_excludes and/or return_type_excludes. Kept for backwards compatibility; treated as both arg_type_excludes and @@ -114,6 +124,9 @@ def __init__(self, name: str, info_config: dict[str, Any] | None = None) -> None self.calldef_excludes: list[str] = [] self.constructor_arg_type_excludes: list[str] = [] self.constructor_signature_excludes: list[list[str]] = [] + # Tri-state (None inherits): automatically add includes for the project + # types used in a class's wrapped signatures. Off unless set. + self.auto_includes: bool | None = None # Tri-state: None means inherit from further up the info tree, so that a # package/module-level setting propagates to classes (hierarchy_attribute # stops at the first non-None value it finds ascending the tree). @@ -157,6 +170,7 @@ def __init__(self, name: str, info_config: dict[str, Any] | None = None) -> None if info_config: for key in [ "arg_type_excludes", + "auto_includes", "calldef_excludes", "constructor_arg_type_excludes", "constructor_signature_excludes", diff --git a/cppwg/info/class_info.py b/cppwg/info/class_info.py index dfde083..e3e17e6 100644 --- a/cppwg/info/class_info.py +++ b/cppwg/info/class_info.py @@ -49,6 +49,11 @@ def __init__(self, name: str, class_config: dict[str, Any] | None = None): self.cpp_names: list[str] = [] self.py_names: list[str] = [] + # Headers auto-resolved for the project types this class's wrapped + # signatures use (populated by PackageInfo.resolve_auto_includes when the + # auto_includes option is enabled; empty otherwise). + self.auto_include_headers: list[str] = [] + # Cache for template parameter names read from the source header, so the # header is not read twice during discovery (once to decide whether the # class is templated, once to record its parameter names). diff --git a/cppwg/info/package_info.py b/cppwg/info/package_info.py index 89e3859..789503c 100644 --- a/cppwg/info/package_info.py +++ b/cppwg/info/package_info.py @@ -23,6 +23,11 @@ # "boost::shared_ptr". Used to reduce a name to its unqualified form. _NAMESPACE_QUALIFIER_RE = re.compile(r"[A-Za-z_]\w*::") +# A bare C++ identifier. Used to pull candidate type names out of a type's +# decl_string when resolving auto-includes; non-project identifiers (namespaces, +# library types, keywords) simply do not resolve against the project map. +_IDENTIFIER_RE = re.compile(r"[A-Za-z_]\w*") + def _strip_namespace_qualifiers(name: str) -> str: """ @@ -502,6 +507,108 @@ def discover_base_class_instantiations(self, source_ns: "namespace_t") -> None: "classes" ) + @staticmethod + def _iter_wrapped_arg_return_types( + class_info: "CppClassInfo", decl: Any + ) -> "Iterator[declarations.type_t]": + """ + Yield the arg/return types the writers will actually wrap for a class. + + Walks the public member functions and constructors of ``decl`` and yields + the pygccxml type of every argument and return type that survives the same + config-driven exclusions the writers apply (``excluded_methods``, + ``return_type_excludes``, ``arg_type_excludes``, + ``constructor_arg_type_excludes``, ``constructor_signature_excludes``, + ``calldef_excludes``, iterator-argument and abstract-class-constructor + skips). A type reached only through an excluded method or constructor is + never yielded, so callers see exactly the types that end up in the + generated wrapper. Shared by dependency pruning and auto-include + resolution so the two cannot diverge from the writers. + + Parameters + ---------- + class_info : CppClassInfo + The class whose exclusion config to honour. + decl : pygccxml.declarations.class_t + The class declaration to walk. + + Yields + ------ + pygccxml.declarations.type_t + Each wrapped argument or return type. + """ + query = access_type_matcher_t("public") + gather = class_info.hierarchy_attribute_gather_flat + calldef_excludes = gather("calldef_excludes") + return_type_excludes = gather("return_type_excludes") + calldef_excludes + arg_type_excludes = gather("arg_type_excludes") + calldef_excludes + ctor_arg_type_excludes = arg_type_excludes + gather( + "constructor_arg_type_excludes" + ) + ctor_signature_excludes = gather("constructor_signature_excludes") + excluded_methods = class_info.excluded_methods or [] + + def excluded(type_string: str, patterns: list[str]) -> bool: + return any( + utils.type_string_matches(type_string, pattern) for pattern in patterns + ) + + def signature_excluded(arg_strings: list[str]) -> bool: + # A constructor is excluded when a constructor_signature_excludes + # entry has the same arity and each argument matches its positional + # pattern (matching the constructor writer). + for exclude_types in ctor_signature_excludes: + if not isinstance(exclude_types, (list, tuple)): + continue + if len(exclude_types) != len(arg_strings): + continue + if all( + utils.type_string_matches(arg_string, exclude_type) + for arg_string, exclude_type in zip(arg_strings, exclude_types) + ): + return True + return False + + for method in decl.member_functions(function=query, allow_empty=True): + if method.name in excluded_methods: + continue + return_type = method.return_type + if return_type is not None and excluded( + return_type.decl_string, return_type_excludes + ): + continue + if any( + excluded(arg.decl_string, arg_type_excludes) + for arg in method.argument_types + ): + continue + yield from method.argument_types + if return_type is not None: + yield return_type + + # Constructors are not wrapped for an abstract class that inherits from an + # abstract base (matching the constructor writer), so its constructor + # arguments cannot introduce a dependency. A base whose related_class is + # None could not be resolved by pygccxml; treat it as non-abstract (skip + # it) rather than dereferencing None. + ctors_wrapped = not ( + decl.is_abstract + and any( + base.related_class is not None and base.related_class.is_abstract + for base in decl.recursive_bases + ) + ) + if ctors_wrapped: + for ctor in decl.constructors(function=query, allow_empty=True): + arg_strings = [arg.decl_string for arg in ctor.argument_types] + if any("iterator" in s.lower() for s in arg_strings): + continue + if any(excluded(s, ctor_arg_type_excludes) for s in arg_strings): + continue + if signature_excluded(arg_strings): + continue + yield from ctor.argument_types + def prune_uninstantiated_dependencies(self, restricted_paths: list[str]) -> None: """ Drop wrapped instantiations that depend on an uninstantiated type. @@ -579,8 +686,6 @@ def prune_uninstantiated_dependencies(self, restricted_paths: list[str]) -> None project_bases = {name.split("<", 1)[0] for name in instantiated} - query = access_type_matcher_t("public") - def uninstantiated(arg_type: "declarations.type_t") -> str | None: for base, full in _referenced_instantiations(arg_type.decl_string): if base in project_bases and full not in instantiated: @@ -588,92 +693,14 @@ def uninstantiated(arg_type: "declarations.type_t") -> str | None: return None def dependency(class_info: "CppClassInfo", decl) -> str | None: - # Only the methods and constructors that will actually be wrapped can - # introduce a dependency, so honour the same config-driven exclusions - # (excluded_methods, return_type_excludes, arg_type_excludes, - # constructor_arg_type_excludes, calldef_excludes) the writers apply - - # a type reached only through an excluded method (e.g. - # VertexMesh::GetFace) is never emitted and must not trigger a drop. - gather = class_info.hierarchy_attribute_gather_flat - calldef_excludes = gather("calldef_excludes") - return_type_excludes = gather("return_type_excludes") + calldef_excludes - arg_type_excludes = gather("arg_type_excludes") + calldef_excludes - ctor_arg_type_excludes = arg_type_excludes + gather( - "constructor_arg_type_excludes" - ) - ctor_signature_excludes = gather("constructor_signature_excludes") - excluded_methods = class_info.excluded_methods or [] - - def excluded(type_string: str, patterns: list[str]) -> bool: - return any( - utils.type_string_matches(type_string, pattern) - for pattern in patterns - ) - - def signature_excluded(arg_strings: list[str]) -> bool: - # A constructor is excluded when a constructor_signature_excludes - # entry has the same arity and each argument matches its - # positional pattern (matching the constructor writer). - for exclude_types in ctor_signature_excludes: - if not isinstance(exclude_types, (list, tuple)): - continue - if len(exclude_types) != len(arg_strings): - continue - if all( - utils.type_string_matches(arg_string, exclude_type) - for arg_string, exclude_type in zip(arg_strings, exclude_types) - ): - return True - return False - - for method in decl.member_functions(function=query, allow_empty=True): - if method.name in excluded_methods: - continue - return_type = method.return_type - if return_type is not None and excluded( - return_type.decl_string, return_type_excludes - ): - continue - if any( - excluded(arg.decl_string, arg_type_excludes) - for arg in method.argument_types - ): - continue - types = list(method.argument_types) - if return_type is not None: - types.append(return_type) - for arg_type in types: - dep = uninstantiated(arg_type) - if dep is not None: - return dep - - # Constructors are not wrapped for an abstract class that inherits - # from an abstract base (matching the constructor writer), so its - # constructor arguments cannot introduce a dependency. - # A base whose related_class is None could not be resolved by - # pygccxml; treat it as non-abstract (skip it) rather than dereferencing - # None, consistent with the constructor writer and the guard in - # discover_base_class_instantiations. - ctors_wrapped = not ( - decl.is_abstract - and any( - base.related_class is not None and base.related_class.is_abstract - for base in decl.recursive_bases - ) - ) - if ctors_wrapped: - for ctor in decl.constructors(function=query, allow_empty=True): - arg_strings = [arg.decl_string for arg in ctor.argument_types] - if any("iterator" in s.lower() for s in arg_strings): - continue - if any(excluded(s, ctor_arg_type_excludes) for s in arg_strings): - continue - if signature_excluded(arg_strings): - continue - for arg_type in ctor.argument_types: - dep = uninstantiated(arg_type) - if dep is not None: - return dep + # Only the arg/return types that will actually be wrapped can + # introduce a dependency; _iter_wrapped_arg_return_types honours the + # same config-driven exclusions the writers apply, so a type reached + # only through an excluded method/constructor is never considered. + for arg_type in self._iter_wrapped_arg_return_types(class_info, decl): + dep = uninstantiated(arg_type) + if dep is not None: + return dep return None for module_info in self.module_collection: @@ -718,6 +745,123 @@ def signature_excluded(arg_strings: list[str]) -> bool: c for c in module_info.class_collection if c.cpp_names ] + def _module_source_locations(self) -> list[Path]: + """ + Return the source-location paths that scope the wrapped source tree. + + A module with no ``source_locations`` wraps everything, so it contributes + the source root; this is decided per module so one module restricting its + locations does not narrow the scope for a module that wraps everything. + Mirrors the scoping used by log_unknown_classes. + + Returns + ------- + list[pathlib.Path] + The directories that bound the project's own source files. + """ + locations: list[Path] = [] + for module_info in self.module_collection: + if module_info.source_locations: + locations.extend(Path(loc) for loc in module_info.source_locations) + else: + locations.append(Path(self.source_root)) + return locations + + def _build_type_header_map(self) -> dict[str, str]: + """ + Map each project class name to the header basename that defines it. + + Builds ``{class name: header}`` by scanning the project's own header files + (restricted to the module source locations, so library headers pulled in + transitively are excluded) for class/struct definitions. The header text + is used - rather than the parsed namespace - because a templated class's + *instantiation* decl reports its location as the point of instantiation + (the generated header collection), not the header that defines the + template; scanning the source finds every class at its definition site, + templated or not, wrapped or not. A name that resolves to more than one + distinct header is ambiguous and dropped, so an auto-include is never + guessed wrongly - such a type must be added via source_includes. + + Returns + ------- + dict[str, str] + Map of class name to defining header basename. + """ + source_locations = self._module_source_locations() + + def in_source_locations(file_path: str) -> bool: + parents = Path(file_path).parents + return any(location in parents for location in source_locations) + + mapping: dict[str, str] = {} + ambiguous: set[str] = set() + for hpp_file_path in self.source_hpp_files: + if not in_source_locations(hpp_file_path): + continue + header = os.path.basename(hpp_file_path) + for _, class_name, _ in utils.find_classes_in_source_file(hpp_file_path): + name = class_name.strip() + if not name: + continue + existing = mapping.get(name) + if existing is None: + mapping[name] = header + elif existing != header: + ambiguous.add(name) + + for name in ambiguous: + mapping.pop(name, None) + return mapping + + def resolve_auto_includes(self) -> None: + """ + Resolve project-type headers for classes with auto_includes enabled. + + For each wrapped class that opts into ``auto_includes``, inspect the types + its wrapped methods/constructors actually expose (via + _iter_wrapped_arg_return_types, so exclusions are honoured), resolve any + that name a project class to that class's header, and record the headers + on ``class_info.auto_include_headers`` for the writer to emit. The class's + own header is dropped (the writer always includes it), as is anything that + does not resolve to a project header (library types are left alone). + + A class using ``common_include_file`` is skipped: the common header + already includes every project header, so per-type includes are moot. + + Must run after the wrapped set is final (post pruning/sorting) so the + resolved headers reflect exactly what will be wrapped. + """ + # Nothing to do unless some wrapped class opts in. + opted_in = [ + class_info + for module_info in self.module_collection + for class_info in module_info.class_collection + if not class_info.excluded + and class_info.hierarchy_attribute("auto_includes") + and not class_info.hierarchy_attribute("common_include_file") + ] + if not opted_in: + return + + type_header_map = self._build_type_header_map() + + for class_info in opted_in: + headers: set[str] = set() + for decl in class_info.decls: + for arg_type in self._iter_wrapped_arg_return_types(class_info, decl): + for name in _IDENTIFIER_RE.findall(arg_type.decl_string): + header = type_header_map.get(name) + if header: + headers.add(header) + + # The writer always includes the class's own header; drop it here. + own_header = class_info.source_file + if not own_header and class_info.decls: + own_header = os.path.basename(class_info.decls[0].location.file_name) + headers.discard(own_header) + + class_info.auto_include_headers = sorted(headers) + @staticmethod def parse_exception_entry(entry: Any) -> tuple[str, str]: """ diff --git a/cppwg/parsers/package_info_parser.py b/cppwg/parsers/package_info_parser.py index 7cf72fd..94f03c9 100644 --- a/cppwg/parsers/package_info_parser.py +++ b/cppwg/parsers/package_info_parser.py @@ -58,6 +58,7 @@ def parse(self) -> PackageInfo: # Base config options that apply to package, modules, classes, etc. base_config: dict[str, Any] = { "arg_type_excludes": [], + "auto_includes": None, "calldef_excludes": [], "constructor_arg_type_excludes": [], "constructor_signature_excludes": [], diff --git a/cppwg/writers/class_writer.py b/cppwg/writers/class_writer.py index 9b69de0..1d4e63e 100644 --- a/cppwg/writers/class_writer.py +++ b/cppwg/writers/class_writer.py @@ -162,6 +162,13 @@ def add(line: str) -> None: if common_include: return "".join(lines) + # Auto-resolved headers for the project types this class's wrapped + # signatures use (empty unless auto_includes is enabled). Emitted with the + # class's other includes; the dedup above drops any that a caster or a + # manual source_includes entry already covers. + for header in self.class_info.auto_include_headers: + add(self._format_include(header)) + # Non-common: the class's explicit source_includes, then its own header. # Caster headers already lead the block, so a caster duplicated here is # skipped rather than emitted a second time. diff --git a/examples/cells/dynamic/config.yaml b/examples/cells/dynamic/config.yaml index b4732ea..e0e0692 100644 --- a/examples/cells/dynamic/config.yaml +++ b/examples/cells/dynamic/config.yaml @@ -9,6 +9,11 @@ common_include_file: False source_includes: - +# Automatically add includes for the project types a wrapped signature uses when +# the class's own header only forward-declares them (e.g. MeshFactory +# returning a PottsMesh). Enabled package-wide here; inherits to every class. +auto_includes: True + exclude_default_args: False # Discover explicit template instantiations (e.g. `template class Node<2>;`) from @@ -74,9 +79,10 @@ modules: # pygccxml fallback since the source-text scan cannot expand macros. - name: MacroMesh + # MeshFactory returns a PottsMesh but its header only knows the + # generic MESH template parameter; the package-level auto_includes resolves + # PottsMesh.hpp from the wrapped signature (no manual source_includes). - name: MeshFactory - source_includes: - - PottsMesh.hpp - name: Node diff --git a/examples/cells/dynamic/wrappers/all/AbstractMesh.cppwg.cpp b/examples/cells/dynamic/wrappers/all/AbstractMesh.cppwg.cpp index e5b275e..c07c72a 100644 --- a/examples/cells/dynamic/wrappers/all/AbstractMesh.cppwg.cpp +++ b/examples/cells/dynamic/wrappers/all/AbstractMesh.cppwg.cpp @@ -2,6 +2,7 @@ #include #include +#include "Node.hpp" #include #include "AbstractMesh.hpp" diff --git a/examples/shapes/wrapper/package_info.yaml b/examples/shapes/wrapper/package_info.yaml index 1e328bf..13bb5aa 100644 --- a/examples/shapes/wrapper/package_info.yaml +++ b/examples/shapes/wrapper/package_info.yaml @@ -105,6 +105,14 @@ modules: source_includes: - + # Automatically add includes for the project types this class's wrapped + # signatures use, when its own header only forward-declares them (so you + # don't have to list them under source_includes). Only project types + # (classes under the module source_locations) are resolved; library types + # are left alone. Off by default; inherits down package/module/class. No + # effect with common_include_file. See examples/cells (MeshFactory). + # auto_includes: True + # List of methods that should not be wrapped. excluded_methods: - ExcludedMethod diff --git a/tests/test_class_writer.py b/tests/test_class_writer.py index ab09916..eb9623e 100644 --- a/tests/test_class_writer.py +++ b/tests/test_class_writer.py @@ -52,6 +52,7 @@ def __init__( self.py_names = py_names if py_names is not None else [name] self.decls = decls if decls is not None else [decl] self.source_file = source_file + self.auto_include_headers = [] self.prefix_code = [] self.suffix_code = [] self.custom_generator_instance = generator @@ -515,6 +516,43 @@ def test_includes_block_dedups_caster_also_in_source_includes(): ) +def test_includes_block_emits_auto_includes(): + """Auto-resolved project headers are emitted ahead of the class's own header.""" + class_info = _FakeClassInfo( + "MeshFactory", + object(), + {"common_include_file": False}, + "MeshFactory.hpp", + ) + writer = _make_writer(class_info) + writer.class_info.auto_include_headers = ["PottsMesh.hpp"] + + assert writer.includes_block() == ( + '#include "PottsMesh.hpp"\n#include "MeshFactory.hpp"\n' + ) + + +def test_includes_block_dedups_auto_include_with_source_include(): + """A header that is both auto-resolved and listed manually emits once.""" + class_info = _FakeClassInfo( + "MeshFactory", + object(), + { + "common_include_file": False, + "source_includes": ["PottsMesh.hpp", ""], + }, + "MeshFactory.hpp", + ) + writer = _make_writer(class_info) + writer.class_info.auto_include_headers = ["PottsMesh.hpp"] + + assert writer.includes_block() == ( + '#include "PottsMesh.hpp"\n' # once, from the auto-include lead + "#include \n" + '#include "MeshFactory.hpp"\n' + ) + + def test_includes_block_emits_typecasters_common(): """Detected caster headers follow the header collection in common mode.""" class_info = _FakeClassInfo( diff --git a/tests/test_package_info.py b/tests/test_package_info.py index bec4467..927e700 100644 --- a/tests/test_package_info.py +++ b/tests/test_package_info.py @@ -637,3 +637,99 @@ def test_parsed_typecasters_validated_once_and_cached(caplog): assert second is first warnings = [r for r in caplog.records if "typecasters" in r.getMessage()] assert len(warnings) == 1 + + +def test_resolve_auto_includes_resolves_project_type_headers(tmp_path): + """A project type used in a wrapped signature resolves to its header. + + The class's own header and library types are not added. + """ + (tmp_path / "PottsMesh.hpp").write_text( + "template class PottsMesh {};\n" + ) + (tmp_path / "MeshFactory.hpp").write_text( + "template class MeshFactory {};\n" + ) + + package = PackageInfo("pkg", {"source_root": str(tmp_path)}) + package.source_hpp_files = [ + str(tmp_path / "PottsMesh.hpp"), + str(tmp_path / "MeshFactory.hpp"), + ] + module = ModuleInfo("mod") + package.add_module(module) + + cls = CppClassInfo("MeshFactory", {"auto_includes": True}) + cls.source_file = "MeshFactory.hpp" + # generateMesh returns a PottsMesh (project type, resolved); a std return arg + # is a library type (ignored); the copy-ctor arg names MeshFactory itself + # (own header, dropped). + method = _FakeCalldef( + return_type="::std::shared_ptr>", name="generateMesh" + ) + ctor = _FakeCalldef( + argument_types=["::MeshFactory> const &"], name="MeshFactory" + ) + cls.decls = [_FakeDecl(methods=[method], constructors=[ctor])] + module.add_class(cls) + + package.resolve_auto_includes() + + assert cls.auto_include_headers == ["PottsMesh.hpp"] + + +def test_resolve_auto_includes_noop_without_opt_in(tmp_path): + """Without auto_includes enabled, no headers are resolved.""" + (tmp_path / "PottsMesh.hpp").write_text("class PottsMesh {};\n") + + package = PackageInfo("pkg", {"source_root": str(tmp_path)}) + package.source_hpp_files = [str(tmp_path / "PottsMesh.hpp")] + module = ModuleInfo("mod") + package.add_module(module) + + cls = CppClassInfo("MeshFactory") # auto_includes not set + cls.source_file = "MeshFactory.hpp" + cls.decls = [_FakeDecl(methods=[_FakeCalldef(return_type="::PottsMesh<2>")])] + module.add_class(cls) + + package.resolve_auto_includes() + + assert cls.auto_include_headers == [] + + +def test_resolve_auto_includes_skipped_for_common_include_file(tmp_path): + """auto_includes is a no-op when the common include file is used.""" + (tmp_path / "PottsMesh.hpp").write_text("class PottsMesh {};\n") + + package = PackageInfo( + "pkg", {"source_root": str(tmp_path), "common_include_file": True} + ) + package.source_hpp_files = [str(tmp_path / "PottsMesh.hpp")] + module = ModuleInfo("mod") + package.add_module(module) + + cls = CppClassInfo("MeshFactory", {"auto_includes": True}) + cls.source_file = "MeshFactory.hpp" + cls.decls = [_FakeDecl(methods=[_FakeCalldef(return_type="::PottsMesh<2>")])] + module.add_class(cls) + + package.resolve_auto_includes() + + assert cls.auto_include_headers == [] + + +def test_build_type_header_map_drops_ambiguous_name(tmp_path): + """A class name defined in two different headers is dropped (unresolvable).""" + (tmp_path / "A.hpp").write_text("class Widget {};\nclass Alpha {};\n") + (tmp_path / "B.hpp").write_text("class Widget {};\nclass Beta {};\n") + + package = PackageInfo("pkg", {"source_root": str(tmp_path)}) + package.source_hpp_files = [str(tmp_path / "A.hpp"), str(tmp_path / "B.hpp")] + module = ModuleInfo("mod") + package.add_module(module) + + mapping = package._build_type_header_map() + + assert mapping["Alpha"] == "A.hpp" + assert mapping["Beta"] == "B.hpp" + assert "Widget" not in mapping # ambiguous -> dropped diff --git a/tests/test_package_info_parser.py b/tests/test_package_info_parser.py index 7f244df..2308b93 100644 --- a/tests/test_package_info_parser.py +++ b/tests/test_package_info_parser.py @@ -136,6 +136,45 @@ def test_package_typecasters_default_to_empty_list(tmp_path): assert package_info.typecasters == [] +def test_parses_class_auto_includes(tmp_path): + """A class-level `auto_includes` flag is parsed onto the class info.""" + config_path = _write_config( + tmp_path, + """ + name: testpkg + modules: + - name: mymod + classes: + - name: Foo + auto_includes: True + """, + ) + + package_info = PackageInfoParser(config_path, str(tmp_path)).parse() + + cls = package_info.module_collection[0].class_collection[0] + assert cls.auto_includes is True + + +def test_class_auto_includes_defaults_to_none(tmp_path): + """`auto_includes` defaults to None (inherit / off) when not set.""" + config_path = _write_config( + tmp_path, + """ + name: testpkg + modules: + - name: mymod + classes: + - name: Foo + """, + ) + + package_info = PackageInfoParser(config_path, str(tmp_path)).parse() + + cls = package_info.module_collection[0].class_collection[0] + assert cls.auto_includes is None + + def test_parses_module_external_bases(tmp_path): """A module-level `external_bases` list is parsed onto the module info.""" config_path = _write_config(