From cd08ed942bffb8bd4803b10ef7aa0d62c957553b Mon Sep 17 00:00:00 2001 From: Jonathan B Coe Date: Sat, 31 Jan 2026 19:57:21 +0000 Subject: [PATCH 1/3] Cleanup for type checking --- py_cppmodel.py | 14 ++++++++++---- pyproject.toml | 6 ------ uv.lock | 11 ----------- 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/py_cppmodel.py b/py_cppmodel.py index 0c47ca1..ef2c741 100644 --- a/py_cppmodel.py +++ b/py_cppmodel.py @@ -2,14 +2,20 @@ from typing import List from typing import Optional -from clang.cindex import AccessSpecifier +from clang.cindex import AccessSpecifier as _AccessSpecifier from clang.cindex import Cursor -from clang.cindex import CursorKind +from clang.cindex import CursorKind as _CursorKind from clang.cindex import Diagnostic -from clang.cindex import ExceptionSpecificationKind +from clang.cindex import ExceptionSpecificationKind as _ExceptionSpecificationKind from clang.cindex import SourceLocation from clang.cindex import TranslationUnit -from clang.cindex import TypeKind +from clang.cindex import TypeKind as _TypeKind + +# Suppress type checking warnings for clang.cindex kinds. +AccessSpecifier: Any = _AccessSpecifier +CursorKind: Any = _CursorKind +ExceptionSpecificationKind: Any = _ExceptionSpecificationKind +TypeKind: Any = _TypeKind def _get_annotations(cursor: Cursor) -> List[str]: diff --git a/pyproject.toml b/pyproject.toml index cc20262..57f3443 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,9 +66,3 @@ force-single-line = true [tool.ruff.format] quote-style = "double" - -[[tool.ty.overrides]] -include = ["py_cppmodel.py"] - -[tool.ty.overrides.rules] -unresolved-attribute = "ignore" diff --git a/uv.lock b/uv.lock index 5be05d7..2938601 100644 --- a/uv.lock +++ b/uv.lock @@ -1218,15 +1218,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, ] -[[package]] -name = "parameterized" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ea/49/00c0c0cc24ff4266025a53e41336b79adaa5a4ebfad214f433d623f9865e/parameterized-0.9.0.tar.gz", hash = "sha256:7fc905272cefa4f364c1a3429cbbe9c0f98b793988efb5bf90aac80f08db09b1", size = 24351, upload-time = "2023-03-27T02:01:11.592Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/2f/804f58f0b856ab3bf21617cccf5b39206e6c4c94c2cd227bde125ea6105f/parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b", size = 20475, upload-time = "2023-03-27T02:01:09.31Z" }, -] - [[package]] name = "parso" version = "0.8.5" @@ -1363,7 +1354,6 @@ dev = [ { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "jupyter" }, - { name = "parameterized" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-xdist" }, @@ -1380,7 +1370,6 @@ requires-dist = [ dev = [ { name = "ipython", specifier = ">=8.12.3" }, { name = "jupyter", specifier = ">=1.1.1" }, - { name = "parameterized", specifier = ">=0.9.0" }, { name = "pre-commit", specifier = ">=4.5.1" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, From ba455a85eb2da52bcc6acbbf1cc238f2c33f1d25 Mon Sep 17 00:00:00 2001 From: Jonathan B Coe Date: Sat, 31 Jan 2026 20:13:33 +0000 Subject: [PATCH 2/3] Restructure under xyz namespace module --- Sandbox.ipynb | 16 +- py_cppmodel.py | 256 ------------------------ pyproject.toml | 11 +- test_parse_standard_library_includes.py | 52 ----- test_py_cppmodel.py | 86 -------- uv.lock | 72 +++---- 6 files changed, 42 insertions(+), 451 deletions(-) delete mode 100644 py_cppmodel.py delete mode 100644 test_parse_standard_library_includes.py delete mode 100644 test_py_cppmodel.py diff --git a/Sandbox.ipynb b/Sandbox.ipynb index 8b0daf4..65788af 100644 --- a/Sandbox.ipynb +++ b/Sandbox.ipynb @@ -9,7 +9,7 @@ }, "outputs": [], "source": [ - "import py_cppmodel" + "import xyz.cppmodel" ] }, { @@ -57,7 +57,7 @@ "\"\"\"\n", "\n", "tu = clang.cindex.TranslationUnit.from_source(\"tmp.cc\", COMPILER_ARGS, unsaved_files=[(\"tmp.cc\", source)])\n", - "model = py_cppmodel.Model(tu)\n", + "model = xyz.cppmodel.Model(tu)\n", "model" ] }, @@ -72,19 +72,11 @@ "source": [ "model.unmodelled_nodes" ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5", - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "py-cppmodel", "language": "python", "name": "python3" }, @@ -98,7 +90,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.14" + "version": "3.10.9" } }, "nbformat": 4, diff --git a/py_cppmodel.py b/py_cppmodel.py deleted file mode 100644 index ef2c741..0000000 --- a/py_cppmodel.py +++ /dev/null @@ -1,256 +0,0 @@ -from typing import Any -from typing import List -from typing import Optional - -from clang.cindex import AccessSpecifier as _AccessSpecifier -from clang.cindex import Cursor -from clang.cindex import CursorKind as _CursorKind -from clang.cindex import Diagnostic -from clang.cindex import ExceptionSpecificationKind as _ExceptionSpecificationKind -from clang.cindex import SourceLocation -from clang.cindex import TranslationUnit -from clang.cindex import TypeKind as _TypeKind - -# Suppress type checking warnings for clang.cindex kinds. -AccessSpecifier: Any = _AccessSpecifier -CursorKind: Any = _CursorKind -ExceptionSpecificationKind: Any = _ExceptionSpecificationKind -TypeKind: Any = _TypeKind - - -def _get_annotations(cursor: Cursor) -> List[str]: - return [c.displayname for c in cursor.get_children() if c.kind == CursorKind.ANNOTATE_ATTR] - - -class Unmodelled: - def __init__(self, cursor: Cursor): - self.location: SourceLocation = cursor.location - self.name: str = cursor.displayname - - def __repr__(self) -> str: - return "".format(self.name, self.location) - - -class Type: - def __init__(self, cindex_type): - self.kind = cindex_type.kind - self.name = cindex_type.spelling - self.is_pointer: bool = self.kind == TypeKind.POINTER - self.is_reference: bool = self.kind == TypeKind.LVALUEREFERENCE - self.is_const: bool = cindex_type.is_const_qualified() - if self.is_pointer or self.is_reference: - self.pointee: Optional[Type] = Type(cindex_type.get_pointee()) - else: - self.pointee = None - - def __repr__(self) -> str: - return "".format(self.name) - - -class Member: - def __init__(self, cursor: Cursor): - self.type: Type = Type(cursor.type) - self.name: str = cursor.spelling - - def __repr__(self) -> str: - return "".format(self.type, self.name) - - -class FunctionArgument: - def __init__(self, type: Type, name: Optional[str] = None): - self.type: Type = type - self.name: Optional[str] = name or None - - def __repr__(self) -> str: - if self.name is None: - return "" - return "".format(self.type, self.name) - - -class _Function: - def __init__(self, cursor): - self.name: str = cursor.spelling - arguments: List[Optional[str]] = [str(x.spelling) or None for x in cursor.get_arguments()] - argument_types: List[Type] = [Type(x) for x in cursor.type.argument_types()] - self.is_noexcept: bool = cursor.exception_specification_kind == ExceptionSpecificationKind.BASIC_NOEXCEPT - self.return_type: Type = Type(cursor.type.get_result()) - self.arguments: List[FunctionArgument] = [] - self.annotations: List[str] = _get_annotations(cursor) - - for t, n in zip(argument_types, arguments, strict=False): - self.arguments.append(FunctionArgument(t, n)) - - def __repr__(self) -> str: - r = "{} {}({})".format( - self.return_type.name, - str(self.name), - ", ".join([a.type.name for a in self.arguments]), - ) - if self.is_noexcept: - r = r + " noexcept" - return r - - -class Function(_Function): - def __init__(self, cursor, namespaces=[]): - _Function.__init__(self, cursor) - self.namespace: str = "::".join(namespaces) - self.qualified_name: str = self.name - if self.namespace: - self.qualified_name = "::".join([self.namespace, self.name]) - - def __repr__(self) -> str: - s = _Function.__repr__(self) - return "".format(s) - - def __eq__(self, f) -> bool: - if self.name != f.name: - return False - if self.namespace != f.namespace: - return False - if len(self.arguments) != len(f.arguments): - return False - for x, fx in zip([arg.type for arg in self.arguments], [arg.type for arg in f.arguments], strict=False): - if x.name != fx.name: - return False - return True - - -class Method(_Function): - def __init__(self, cursor): - _Function.__init__(self, cursor) - self.is_const: bool = cursor.is_const_method() - self.is_virtual: bool = cursor.is_virtual_method() - self.is_pure_virtual: bool = cursor.is_pure_virtual_method() - self.is_public: bool = cursor.access_specifier == AccessSpecifier.PUBLIC - - def __repr__(self) -> str: - s = _Function.__repr__(self) - if self.is_const: - s = "{} const".format(s) - if self.is_pure_virtual: - s = "virtual {} = 0".format(s) - elif self.is_virtual: - s = "virtual {}".format(s) - return "".format(s) - - -class Class: - def __init__(self, cursor: Cursor, namespaces: List[str]): - self.name: str = cursor.spelling - self.namespace: str = "::".join(namespaces) - self.qualified_name: str = self.name - if self.namespace: - self.qualified_name = "::".join([self.namespace, self.name]) - self.constructors: List[Method] = [] - self.methods: List[Method] = [] - self.members: List[Member] = [] - self.annotations = _get_annotations(cursor) - self.base_classes = [] - # FIXME: populate these fields with AST info - self.source_file = str(cursor.location.file) - self.source_line = int(cursor.location.line) - self.source_column = int(cursor.location.column) - - for c in cursor.get_children(): - if c.kind == CursorKind.CXX_METHOD and c.type.kind == TypeKind.FUNCTIONPROTO: - self.methods.append(Method(c)) - elif c.kind == CursorKind.CONSTRUCTOR and c.type.kind == TypeKind.FUNCTIONPROTO: - self.constructors.append(Method(c)) - elif c.kind == CursorKind.FIELD_DECL: - self.members.append(Member(c)) - elif c.kind == CursorKind.CXX_BASE_SPECIFIER: - self.base_classes.append(c.type.spelling) - - def __repr__(self) -> str: - return "".format(self.name) - - -class Model: - def __init__(self, translation_unit: TranslationUnit): - """Create a model from a translation unit.""" - self.filename: str = translation_unit.spelling - self.functions: List[Function] = [] - self.classes: List[Class] = [] - self.unmodelled_nodes: List[Unmodelled] = [] - # Keep a reference to the translation unit to prevent it from being garbage collected. - self.translation_unit: TranslationUnit = translation_unit - - def is_error_in_current_file(diagnostic: Diagnostic) -> bool: - if str(diagnostic.location.file) != str(translation_unit.spelling): - return False - if diagnostic.severity == Diagnostic.Error: - return True - if diagnostic.severity == Diagnostic.Fatal: - return True - return False - - errors: List[Diagnostic] = [d for d in translation_unit.diagnostics if is_error_in_current_file(d)] - if errors: - joined_errors = "\n".join(str(e) for e in errors) - raise ValueError(f"Errors in source file:{joined_errors}") - - self._add_child_nodes(translation_unit.cursor, []) - - def __repr__(self) -> str: - return "".format( - self.filename, - [c.name for c in self.classes], - [f.name for f in self.functions], - ) - - def extend(self, translation_unit: TranslationUnit): - # Extend an existing model with contents of a new translation unit. - m = Model(translation_unit) - # Check for duplicates and inconsistencies. - for new_class in m.classes: - is_new = True - for old_class in self.classes: - if new_class.qualified_name == old_class.qualified_name: - if new_class.source_file != old_class.source_file: - raise Exception( - "Class {} is defined in multiple locations: {} {}".format( - old_class.qualified_name, - old_class.source_file, - new_class.source_file, - ) - ) - # Move on as there can only be one match - is_new = False - break - - if is_new: - self.classes.append(new_class) - - # We only look at declarations for functions so won't raise exceptions - for new_function in m.functions: - is_new = True - for old_function in self.functions: - if new_function == old_function: - is_new = False - break - if is_new: - self.functions.append(new_function) - - def _add_child_nodes(self, cursor: Any, namespaces: List[str] = []): - namespaces = namespaces or [] - for c in cursor.get_children(): - try: - c.kind - except ValueError: # Handle unknown cursor kind - # TODO(jbcoe): Fix cindex.py upstream to avoid needing to do this. - continue - if c.kind == CursorKind.CLASS_DECL or c.kind == CursorKind.STRUCT_DECL: - if c.location.file.name == self.filename: - self.classes.append(Class(c, namespaces)) - elif c.kind == CursorKind.FUNCTION_DECL and c.type.kind == TypeKind.FUNCTIONPROTO: - if c.location.file.name == self.filename: - self.functions.append(Function(c, namespaces)) - elif c.kind == CursorKind.NAMESPACE: - child_namespaces = list(namespaces) - child_namespaces.append(c.spelling) - - self._add_child_nodes(c, child_namespaces) - else: - if c.location.file.name == self.filename: - self.unmodelled_nodes.append(Unmodelled(c)) diff --git a/pyproject.toml b/pyproject.toml index 57f3443..064a434 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "py-cppmodel" +name = "xyz-cppmodel" version = "0.0.1" description = "A Python wrapper around clang's python bindings to generate a simple Python model of a C++ translation unit." readme = "README.md" @@ -31,15 +31,8 @@ addopts = "-n auto -v" requires = ["hatchling"] build-backend = "hatchling.build" -[tool.hatch.build.targets.sdist] -include = [ - "py_cppmodel.py", - "LICENSE", - "README.md", -] - [tool.hatch.build.targets.wheel] -include = ["py_cppmodel.py"] +packages = ["src/xyz"] [tool.ruff] line-length = 120 diff --git a/test_parse_standard_library_includes.py b/test_parse_standard_library_includes.py deleted file mode 100644 index 716f7d4..0000000 --- a/test_parse_standard_library_includes.py +++ /dev/null @@ -1,52 +0,0 @@ -import pytest -from clang.cindex import TranslationUnit - -import py_cppmodel - -COMPILER_ARGS = [ - "-x", - "c++", - "-std=c++20", - "-I/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1", - "-I/Library/Developer/CommandLineTools/usr/include", -] - - -@pytest.mark.parametrize( - "include", - [ - "algorithm", - "any", - "array", - "deque", - "forward_list", - "functional", - "iterator", - "list", - "map", - "memory", - "numeric", - "optional", - "queue", - "set", - "stack", - "string", - "tuple", - "type_traits", - "unordered_map", - "unordered_set", - "utility", - "variant", - "vector", - ], -) -def test_include(include): - source = f"#include <{include}>" - tu = TranslationUnit.from_source( - "t.cc", - COMPILER_ARGS, - unsaved_files=[("t.cc", source)], - ) - - # This should not raise an exception. - py_cppmodel.Model(tu) diff --git a/test_py_cppmodel.py b/test_py_cppmodel.py deleted file mode 100644 index 908262b..0000000 --- a/test_py_cppmodel.py +++ /dev/null @@ -1,86 +0,0 @@ -import pytest -from clang.cindex import TranslationUnit - -import py_cppmodel - -COMPILER_ARGS = [ - "-x", - "c++", - "-std=c++20", - "-I/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1", - "-I/Library/Developer/CommandLineTools/usr/include", -] - -SOURCE = """\ -int z = 0; - -struct __attribute__((annotate("A"))) A { - int a; - double b; - char c[8]; - - __attribute__((annotate("foo"))) int foo(int); -}; - -template -class B { - T t; - T wibble(T); -}; - -double bar(double); - -int main() {} -""" - - -@pytest.fixture -def model(): - tu = TranslationUnit.from_source( - "sample.cc", - COMPILER_ARGS, - unsaved_files=[("sample.cc", SOURCE)], - ) - return py_cppmodel.Model(tu) - - -def test_filename(model): - assert model.filename == "sample.cc" - - -def test_functions(model): - assert len(model.functions) == 2 - assert str(model.functions[0]) == "" - assert str(model.functions[1]) == "" - - -def test_classes(model): - assert len(model.classes) == 1 - assert str(model.classes[0]) == "" - - assert len(model.classes[0].annotations) == 1 - assert model.classes[0].annotations[0] == "A" - - -def test_class_members(model): - assert len(model.classes[0].members) == 3 - assert str(model.classes[0].members[0]) == " a>" - assert str(model.classes[0].members[1]) == " b>" - assert str(model.classes[0].members[2]) == " c>" - - assert len(model.classes[0].methods) == 1 - assert str(model.classes[0].methods[0]) == "" - assert len(model.classes[0].methods[0].annotations) == 1 - assert model.classes[0].methods[0].annotations[0] == "foo" - - -def test_unmodelled_nodes(model): - assert len(model.unmodelled_nodes) == 2 - assert ( - str(model.unmodelled_nodes[0]) - == ">" - ) - assert ( - str(model.unmodelled_nodes[1]) - == " >" - ) diff --git a/uv.lock b/uv.lock index 2938601..879ac3e 100644 --- a/uv.lock +++ b/uv.lock @@ -1340,42 +1340,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] -[[package]] -name = "py-cppmodel" -version = "0.0.1" -source = { editable = "." } -dependencies = [ - { name = "clang" }, - { name = "libclang" }, -] - -[package.dev-dependencies] -dev = [ - { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "jupyter" }, - { name = "pre-commit" }, - { name = "pytest" }, - { name = "pytest-xdist" }, - { name = "ty" }, -] - -[package.metadata] -requires-dist = [ - { name = "clang", specifier = ">=14.0" }, - { name = "libclang", specifier = ">=18.1.1" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "ipython", specifier = ">=8.12.3" }, - { name = "jupyter", specifier = ">=1.1.1" }, - { name = "pre-commit", specifier = ">=4.5.1" }, - { name = "pytest", specifier = ">=9.0.2" }, - { name = "pytest-xdist", specifier = ">=3.8.0" }, - { name = "ty", specifier = ">=0.0.14" }, -] - [[package]] name = "pycparser" version = "3.0" @@ -2059,3 +2023,39 @@ sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b wheels = [ { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, ] + +[[package]] +name = "xyz-cppmodel" +version = "0.0.1" +source = { editable = "." } +dependencies = [ + { name = "clang" }, + { name = "libclang" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jupyter" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-xdist" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "clang", specifier = ">=14.0" }, + { name = "libclang", specifier = ">=18.1.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "ipython", specifier = ">=8.12.3" }, + { name = "jupyter", specifier = ">=1.1.1" }, + { name = "pre-commit", specifier = ">=4.5.1" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, + { name = "ty", specifier = ">=0.0.14" }, +] From 5131e8edf8dfc6d078d2aeba3eabbbec0380c2e3 Mon Sep 17 00:00:00 2001 From: Jonathan B Coe Date: Sat, 31 Jan 2026 20:16:36 +0000 Subject: [PATCH 3/3] Cleanup --- src/xyz/cppmodel.py | 256 ++++++++++++++++++ tests/test_cppmodel.py | 86 ++++++ tests/test_parse_standard_library_includes.py | 52 ++++ uv.lock | 51 +--- 4 files changed, 399 insertions(+), 46 deletions(-) create mode 100644 src/xyz/cppmodel.py create mode 100644 tests/test_cppmodel.py create mode 100644 tests/test_parse_standard_library_includes.py diff --git a/src/xyz/cppmodel.py b/src/xyz/cppmodel.py new file mode 100644 index 0000000..3ded5ec --- /dev/null +++ b/src/xyz/cppmodel.py @@ -0,0 +1,256 @@ +from typing import Any +from typing import List +from typing import Optional + +from clang.cindex import AccessSpecifier as _AccessSpecifier +from clang.cindex import Cursor +from clang.cindex import CursorKind as _CursorKind +from clang.cindex import Diagnostic +from clang.cindex import ExceptionSpecificationKind as _ExceptionSpecificationKind +from clang.cindex import SourceLocation +from clang.cindex import TranslationUnit +from clang.cindex import TypeKind as _TypeKind + +# Suppress type checking warnings for clang.cindex kinds. +AccessSpecifier: Any = _AccessSpecifier +CursorKind: Any = _CursorKind +ExceptionSpecificationKind: Any = _ExceptionSpecificationKind +TypeKind: Any = _TypeKind + + +def _get_annotations(cursor: Cursor) -> List[str]: + return [c.displayname for c in cursor.get_children() if c.kind == CursorKind.ANNOTATE_ATTR] + + +class Unmodelled: + def __init__(self, cursor: Cursor): + self.location: SourceLocation = cursor.location + self.name: str = cursor.displayname + + def __repr__(self) -> str: + return "".format(self.name, self.location) + + +class Type: + def __init__(self, cindex_type): + self.kind = cindex_type.kind + self.name = cindex_type.spelling + self.is_pointer: bool = self.kind == TypeKind.POINTER + self.is_reference: bool = self.kind == TypeKind.LVALUEREFERENCE + self.is_const: bool = cindex_type.is_const_qualified() + if self.is_pointer or self.is_reference: + self.pointee: Optional[Type] = Type(cindex_type.get_pointee()) + else: + self.pointee = None + + def __repr__(self) -> str: + return "".format(self.name) + + +class Member: + def __init__(self, cursor: Cursor): + self.type: Type = Type(cursor.type) + self.name: str = cursor.spelling + + def __repr__(self) -> str: + return "".format(self.type, self.name) + + +class FunctionArgument: + def __init__(self, type: Type, name: Optional[str] = None): + self.type: Type = type + self.name: Optional[str] = name or None + + def __repr__(self) -> str: + if self.name is None: + return "" + return "".format(self.type, self.name) + + +class _Function: + def __init__(self, cursor): + self.name: str = cursor.spelling + arguments: List[Optional[str]] = [str(x.spelling) or None for x in cursor.get_arguments()] + argument_types: List[Type] = [Type(x) for x in cursor.type.argument_types()] + self.is_noexcept: bool = cursor.exception_specification_kind == ExceptionSpecificationKind.BASIC_NOEXCEPT + self.return_type: Type = Type(cursor.type.get_result()) + self.arguments: List[FunctionArgument] = [] + self.annotations: List[str] = _get_annotations(cursor) + + for t, n in zip(argument_types, arguments, strict=False): + self.arguments.append(FunctionArgument(t, n)) + + def __repr__(self) -> str: + r = "{} {}({})".format( + self.return_type.name, + str(self.name), + ", ".join([a.type.name for a in self.arguments]), + ) + if self.is_noexcept: + r = r + " noexcept" + return r + + +class Function(_Function): + def __init__(self, cursor, namespaces=[]): + _Function.__init__(self, cursor) + self.namespace: str = "::".join(namespaces) + self.qualified_name: str = self.name + if self.namespace: + self.qualified_name = "::".join([self.namespace, self.name]) + + def __repr__(self) -> str: + s = _Function.__repr__(self) + return "".format(s) + + def __eq__(self, f) -> bool: + if self.name != f.name: + return False + if self.namespace != f.namespace: + return False + if len(self.arguments) != len(f.arguments): + return False + for x, fx in zip([arg.type for arg in self.arguments], [arg.type for arg in f.arguments], strict=False): + if x.name != fx.name: + return False + return True + + +class Method(_Function): + def __init__(self, cursor): + _Function.__init__(self, cursor) + self.is_const: bool = cursor.is_const_method() + self.is_virtual: bool = cursor.is_virtual_method() + self.is_pure_virtual: bool = cursor.is_pure_virtual_method() + self.is_public: bool = cursor.access_specifier == AccessSpecifier.PUBLIC + + def __repr__(self) -> str: + s = _Function.__repr__(self) + if self.is_const: + s = "{} const".format(s) + if self.is_pure_virtual: + s = "virtual {} = 0".format(s) + elif self.is_virtual: + s = "virtual {}".format(s) + return "".format(s) + + +class Class: + def __init__(self, cursor: Cursor, namespaces: List[str]): + self.name: str = cursor.spelling + self.namespace: str = "::".join(namespaces) + self.qualified_name: str = self.name + if self.namespace: + self.qualified_name = "::".join([self.namespace, self.name]) + self.constructors: List[Method] = [] + self.methods: List[Method] = [] + self.members: List[Member] = [] + self.annotations = _get_annotations(cursor) + self.base_classes = [] + # FIXME: populate these fields with AST info + self.source_file = str(cursor.location.file) + self.source_line = int(cursor.location.line) + self.source_column = int(cursor.location.column) + + for c in cursor.get_children(): + if c.kind == CursorKind.CXX_METHOD and c.type.kind == TypeKind.FUNCTIONPROTO: + self.methods.append(Method(c)) + elif c.kind == CursorKind.CONSTRUCTOR and c.type.kind == TypeKind.FUNCTIONPROTO: + self.constructors.append(Method(c)) + elif c.kind == CursorKind.FIELD_DECL: + self.members.append(Member(c)) + elif c.kind == CursorKind.CXX_BASE_SPECIFIER: + self.base_classes.append(c.type.spelling) + + def __repr__(self) -> str: + return "".format(self.name) + + +class Model: + def __init__(self, translation_unit: TranslationUnit): + """Create a model from a translation unit.""" + self.filename: str = translation_unit.spelling + self.functions: List[Function] = [] + self.classes: List[Class] = [] + self.unmodelled_nodes: List[Unmodelled] = [] + # Keep a reference to the translation unit to prevent it from being garbage collected. + self.translation_unit: TranslationUnit = translation_unit + + def is_error_in_current_file(diagnostic: Diagnostic) -> bool: + if str(diagnostic.location.file) != str(translation_unit.spelling): + return False + if diagnostic.severity == Diagnostic.Error: + return True + if diagnostic.severity == Diagnostic.Fatal: + return True + return False + + errors: List[Diagnostic] = [d for d in translation_unit.diagnostics if is_error_in_current_file(d)] + if errors: + joined_errors = "\n".join(str(e) for e in errors) + raise ValueError(f"Errors in source file:{joined_errors}") + + self._add_child_nodes(translation_unit.cursor, []) + + def __repr__(self) -> str: + return "".format( + self.filename, + [c.name for c in self.classes], + [f.name for f in self.functions], + ) + + def extend(self, translation_unit: TranslationUnit): + # Extend an existing model with contents of a new translation unit. + m = Model(translation_unit) + # Check for duplicates and inconsistencies. + for new_class in m.classes: + is_new = True + for old_class in self.classes: + if new_class.qualified_name == old_class.qualified_name: + if new_class.source_file != old_class.source_file: + raise Exception( + "Class {} is defined in multiple locations: {} {}".format( + old_class.qualified_name, + old_class.source_file, + new_class.source_file, + ) + ) + # Move on as there can only be one match + is_new = False + break + + if is_new: + self.classes.append(new_class) + + # We only look at declarations for functions so won't raise exceptions + for new_function in m.functions: + is_new = True + for old_function in self.functions: + if new_function == old_function: + is_new = False + break + if is_new: + self.functions.append(new_function) + + def _add_child_nodes(self, cursor: Any, namespaces: List[str] = []): + namespaces = namespaces or [] + for c in cursor.get_children(): + try: + c.kind + except ValueError: # Handle unknown cursor kind + # TODO(jbcoe): Fix cindex.py upstream to avoid needing to do this. + continue + if c.kind == CursorKind.CLASS_DECL or c.kind == CursorKind.STRUCT_DECL: + if c.location.file.name == self.filename: + self.classes.append(Class(c, namespaces)) + elif c.kind == CursorKind.FUNCTION_DECL and c.type.kind == TypeKind.FUNCTIONPROTO: + if c.location.file.name == self.filename: + self.functions.append(Function(c, namespaces)) + elif c.kind == CursorKind.NAMESPACE: + child_namespaces = list(namespaces) + child_namespaces.append(c.spelling) + + self._add_child_nodes(c, child_namespaces) + else: + if c.location.file.name == self.filename: + self.unmodelled_nodes.append(Unmodelled(c)) diff --git a/tests/test_cppmodel.py b/tests/test_cppmodel.py new file mode 100644 index 0000000..b5ca4da --- /dev/null +++ b/tests/test_cppmodel.py @@ -0,0 +1,86 @@ +import pytest +from clang.cindex import TranslationUnit + +import xyz.cppmodel + +COMPILER_ARGS = [ + "-x", + "c++", + "-std=c++20", + "-I/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1", + "-I/Library/Developer/CommandLineTools/usr/include", +] + +SOURCE = """\ +int z = 0; + +struct __attribute__((annotate("A"))) A { + int a; + double b; + char c[8]; + + __attribute__((annotate("foo"))) int foo(int); +}; + +template +class B { + T t; + T wibble(T); +}; + +double bar(double); + +int main() {} +""" + + +@pytest.fixture +def model(): + tu = TranslationUnit.from_source( + "sample.cc", + COMPILER_ARGS, + unsaved_files=[("sample.cc", SOURCE)], + ) + return xyz.cppmodel.Model(tu) + + +def test_filename(model): + assert model.filename == "sample.cc" + + +def test_functions(model): + assert len(model.functions) == 2 + assert str(model.functions[0]) == "" + assert str(model.functions[1]) == "" + + +def test_classes(model): + assert len(model.classes) == 1 + assert str(model.classes[0]) == "" + + assert len(model.classes[0].annotations) == 1 + assert model.classes[0].annotations[0] == "A" + + +def test_class_members(model): + assert len(model.classes[0].members) == 3 + assert str(model.classes[0].members[0]) == " a>" + assert str(model.classes[0].members[1]) == " b>" + assert str(model.classes[0].members[2]) == " c>" + + assert len(model.classes[0].methods) == 1 + assert str(model.classes[0].methods[0]) == "" + assert len(model.classes[0].methods[0].annotations) == 1 + assert model.classes[0].methods[0].annotations[0] == "foo" + + +def test_unmodelled_nodes(model): + assert len(model.unmodelled_nodes) == 2 + assert ( + str(model.unmodelled_nodes[0]) + == ">" + ) + assert ( + str(model.unmodelled_nodes[1]) + == " >" + ) diff --git a/tests/test_parse_standard_library_includes.py b/tests/test_parse_standard_library_includes.py new file mode 100644 index 0000000..df45ad8 --- /dev/null +++ b/tests/test_parse_standard_library_includes.py @@ -0,0 +1,52 @@ +import pytest +from clang.cindex import TranslationUnit + +import xyz.cppmodel + +COMPILER_ARGS = [ + "-x", + "c++", + "-std=c++20", + "-I/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1", + "-I/Library/Developer/CommandLineTools/usr/include", +] + + +@pytest.mark.parametrize( + "include", + [ + "algorithm", + "any", + "array", + "deque", + "forward_list", + "functional", + "iterator", + "list", + "map", + "memory", + "numeric", + "optional", + "queue", + "set", + "stack", + "string", + "tuple", + "type_traits", + "unordered_map", + "unordered_set", + "utility", + "variant", + "vector", + ], +) +def test_include(include): + source = f"#include <{include}>" + tu = TranslationUnit.from_source( + "t.cc", + COMPILER_ARGS, + unsaved_files=[("t.cc", source)], + ) + + # This should not raise an exception. + xyz.cppmodel.Model(tu) diff --git a/uv.lock b/uv.lock index 857dcaf..0bb40de 100644 --- a/uv.lock +++ b/uv.lock @@ -3,8 +3,7 @@ revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version >= '3.11' and python_full_version < '3.13'", + "python_full_version >= '3.11' and python_full_version < '3.14'", "python_full_version < '3.11'", ] @@ -352,11 +351,11 @@ wheels = [ [[package]] name = "clang" -version = "14.0" +version = "21.1.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/1f/0cde370a9b3f51d8aee106c50d0acf84f088de0d8c99e65b924443314c13/clang-14.0.tar.gz", hash = "sha256:0fe241386d3b780666e032d8c2e482b1d434880185c685071aa38b78317e969f", size = 31219, upload-time = "2022-02-12T17:56:59.807Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/9b/956a03195656847c4e3620dab9c36c9e53aecf685a6c3f0bc1ce025d7ec3/clang-21.1.7.tar.gz", hash = "sha256:01d5602b3fff77fef6d691a193d7aead4766b0f11d789d596200f3334a88f4b8", size = 8600, upload-time = "2025-12-18T22:04:51.886Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/ae/4c5e775b3bafc86fde8db6e4f3fed014651312f33b5fd0468e4bf6848187/clang-14.0-py3-none-any.whl", hash = "sha256:049c7f7740f46f733fc96b5395910ffc57f56d4d61bca65b5556e9772d146deb", size = 31266, upload-time = "2022-02-12T17:56:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/50/a4/608c542925949b300a295baa422b568f835044c5e3ad20820676b840228a/clang-21.1.7-py3-none-any.whl", hash = "sha256:23ee8f7b62af648009aee5139516b2a2a9320680dbce6e42a53e48bd5e8983ea", size = 40240, upload-time = "2025-12-18T22:04:50.636Z" }, ] [[package]] @@ -610,8 +609,7 @@ version = "9.9.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version >= '3.11' and python_full_version < '3.13'", + "python_full_version >= '3.11' and python_full_version < '3.14'", ] dependencies = [ { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, @@ -1341,45 +1339,6 @@ wheels = [ ] [[package]] -<<<<<<< HEAD -======= -name = "py-cppmodel" -version = "0.0.1" -source = { editable = "." } -dependencies = [ - { name = "clang" }, - { name = "libclang" }, -] - -[package.dev-dependencies] -dev = [ - { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "jupyter" }, - { name = "pre-commit" }, - { name = "pytest" }, - { name = "pytest-xdist" }, - { name = "ty" }, -] - -[package.metadata] -requires-dist = [ - { name = "clang", specifier = ">=14.0" }, - { name = "libclang", specifier = ">=18.1.1" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "ipython", specifier = ">=8.12.3" }, - { name = "jupyter", specifier = ">=1.1.1" }, - { name = "pre-commit", specifier = ">=4.5.1" }, - { name = "pytest", specifier = ">=9.0.2" }, - { name = "pytest-xdist", specifier = ">=3.8.0" }, - { name = "ty", specifier = ">=0.0.14" }, -] - -[[package]] ->>>>>>> origin/main name = "pycparser" version = "3.0" source = { registry = "https://pypi.org/simple" }