Skip to content

Commit fa41749

Browse files
committed
Conan: Replace AST Injection with Inheritance
1 parent 81b743e commit fa41749

File tree

5 files changed

+282
-289
lines changed

5 files changed

+282
-289
lines changed

cppython/plugins/conan/builder.py

Lines changed: 119 additions & 175 deletions
Original file line numberDiff line numberDiff line change
@@ -1,193 +1,136 @@
11
"""Construction of Conan data"""
22

33
from pathlib import Path
4-
from string import Template
5-
from textwrap import dedent
64

7-
import libcst as cst
85
from pydantic import DirectoryPath
96

107
from cppython.plugins.conan.schema import ConanDependency
118

129

13-
class RequiresTransformer(cst.CSTTransformer):
14-
"""Transformer to add or update the `requires` attribute in a ConanFile class."""
10+
class Builder:
11+
"""Aids in building the information needed for the Conan plugin"""
1512

16-
def __init__(self, dependencies: list[ConanDependency]) -> None:
17-
"""Initialize the transformer with a list of dependencies."""
18-
self.dependencies = dependencies
13+
def __init__(self) -> None:
14+
"""Initialize the builder"""
15+
self._filename = 'conanfile.py'
1916

20-
def _create_requires_assignment(self) -> cst.Assign:
21-
"""Create a `requires` assignment statement."""
22-
return cst.Assign(
23-
targets=[cst.AssignTarget(cst.Name(value='requires'))],
24-
value=cst.List(
25-
[cst.Element(cst.SimpleString(f'"{dependency.requires()}"')) for dependency in self.dependencies]
26-
),
27-
)
17+
@staticmethod
18+
def _create_base_conanfile(
19+
base_file: Path,
20+
dependencies: list[ConanDependency],
21+
dependency_groups: dict[str, list[ConanDependency]],
22+
) -> None:
23+
"""Creates a conanfile_base.py with CPPython managed dependencies."""
24+
test_dependencies = dependency_groups.get('test', [])
2825

29-
def leave_ClassDef(self, original_node: cst.ClassDef, updated_node: cst.ClassDef) -> cst.BaseStatement:
30-
"""Modify the class definition to include or update 'requires'.
26+
# Generate requirements method content
27+
requires_lines = []
28+
for dep in dependencies:
29+
requires_lines.append(f' self.requires("{dep.requires()}")')
30+
requires_content = '\n'.join(requires_lines) if requires_lines else ' pass # No requirements'
31+
32+
# Generate build_requirements method content
33+
test_requires_lines = []
34+
for dep in test_dependencies:
35+
test_requires_lines.append(f' self.test_requires("{dep.requires()}")')
36+
test_requires_content = (
37+
'\n'.join(test_requires_lines) if test_requires_lines else ' pass # No test requirements'
38+
)
3139

32-
Args:
33-
original_node: The original class definition.
34-
updated_node: The updated class definition.
40+
content = f'''"""CPPython managed base ConanFile.
3541
36-
Returns: The modified class definition.
37-
"""
38-
if self._is_conanfile_class(original_node):
39-
updated_node = self._update_requires(updated_node)
40-
return updated_node
42+
This file is auto-generated by CPPython. Do not edit manually.
43+
Dependencies are managed through pyproject.toml.
44+
"""
4145
42-
@staticmethod
43-
def _is_conanfile_class(class_node: cst.ClassDef) -> bool:
44-
"""Check if the class inherits from ConanFile.
46+
from conan import ConanFile
4547
46-
Args:
47-
class_node: The class definition to check.
4848
49-
Returns: True if the class inherits from ConanFile, False otherwise.
50-
"""
51-
return any((isinstance(base.value, cst.Name) and base.value.value == 'ConanFile') for base in class_node.bases)
52-
53-
def _update_requires(self, updated_node: cst.ClassDef) -> cst.ClassDef:
54-
"""Update or add a 'requires' assignment in a ConanFile class definition."""
55-
# Check if 'requires' is already defined
56-
for body_statement_line in updated_node.body.body:
57-
if not isinstance(body_statement_line, cst.SimpleStatementLine):
58-
continue
59-
for assignment_statement in body_statement_line.body:
60-
if not isinstance(assignment_statement, cst.Assign):
61-
continue
62-
for target in assignment_statement.targets:
63-
if not isinstance(target.target, cst.Name) or target.target.value != 'requires':
64-
continue
65-
# Replace only the assignment within the SimpleStatementLine
66-
return self._replace_requires(updated_node, body_statement_line, assignment_statement)
67-
68-
# Find the last attribute assignment before methods
69-
last_attribute = None
70-
for body_statement_line in updated_node.body.body:
71-
if not isinstance(body_statement_line, cst.SimpleStatementLine):
72-
break
73-
if not body_statement_line.body:
74-
break
75-
if not isinstance(body_statement_line.body[0], cst.Assign):
76-
break
77-
last_attribute = body_statement_line
78-
79-
# Construct a new statement for the 'requires' attribute
80-
new_statement = cst.SimpleStatementLine(
81-
body=[self._create_requires_assignment()],
82-
)
49+
class CPPythonBase(ConanFile):
50+
"""Base ConanFile with CPPython managed dependencies."""
8351
84-
# Insert the new statement after the last attribute assignment
85-
if last_attribute is not None:
86-
new_body = [item for item in updated_node.body.body]
87-
index = new_body.index(last_attribute)
88-
new_body.insert(index + 1, new_statement)
89-
else:
90-
new_body = [new_statement] + [item for item in updated_node.body.body]
91-
return updated_node.with_changes(body=updated_node.body.with_changes(body=new_body))
92-
93-
def _replace_requires(
94-
self, updated_node: cst.ClassDef, body_statement_line: cst.SimpleStatementLine, assignment_statement: cst.Assign
95-
) -> cst.ClassDef:
96-
"""Replace the existing 'requires' assignment with a new one, preserving other statements on the same line."""
97-
new_value = cst.List(
98-
[cst.Element(cst.SimpleString(f'"{dependency.requires()}"')) for dependency in self.dependencies]
99-
)
100-
new_assignment = assignment_statement.with_changes(value=new_value)
101-
102-
# Replace only the relevant assignment in the SimpleStatementLine
103-
new_body = [
104-
new_assignment if statement is assignment_statement else statement for statement in body_statement_line.body
105-
]
106-
new_statement_line = body_statement_line.with_changes(body=new_body)
107-
108-
# Replace the statement line in the class body
109-
return updated_node.with_changes(
110-
body=updated_node.body.with_changes(
111-
body=[new_statement_line if item is body_statement_line else item for item in updated_node.body.body]
112-
)
113-
)
52+
def requirements(self):
53+
"""CPPython managed requirements."""
54+
{requires_content}
11455
115-
116-
class Builder:
117-
"""Aids in building the information needed for the Conan plugin"""
118-
119-
def __init__(self) -> None:
120-
"""Initialize the builder"""
121-
self._filename = 'conanfile.py'
56+
def build_requirements(self):
57+
"""CPPython managed build and test requirements."""
58+
{test_requires_content}
59+
'''
60+
base_file.write_text(content, encoding='utf-8')
12261

12362
@staticmethod
12463
def _create_conanfile(
12564
conan_file: Path,
126-
dependencies: list[ConanDependency],
127-
dependency_groups: dict[str, list[ConanDependency]],
12865
name: str,
12966
version: str,
13067
) -> None:
131-
"""Creates a conanfile.py file with the necessary content."""
132-
template_string = """
133-
import os
134-
from conan import ConanFile
135-
from conan.tools.cmake import CMake, CMakeDeps, CMakeToolchain, cmake_layout
136-
from conan.tools.files import copy
137-
138-
class AutoPackage(ConanFile):
139-
name = "${name}"
140-
version = "${version}"
141-
settings = "os", "compiler", "build_type", "arch"
142-
requires = ${dependencies}
143-
test_requires = ${test_requires}
144-
145-
def layout(self):
146-
cmake_layout(self)
147-
148-
def generate(self):
149-
deps = CMakeDeps(self)
150-
deps.generate()
151-
tc = CMakeToolchain(self)
152-
tc.user_presets_path = None
153-
tc.generate()
154-
155-
def build(self):
156-
cmake = CMake(self)
157-
cmake.configure()
158-
cmake.build()
159-
160-
def package(self):
161-
cmake = CMake(self)
162-
cmake.install()
163-
164-
def package_info(self):
165-
# Use native CMake config files to preserve FILE_SET information for C++ modules
166-
# This tells CMakeDeps to skip generating files and use the package's native config
167-
self.cpp_info.set_property("cmake_find_mode", "none")
168-
self.cpp_info.builddirs = ["."]
169-
170-
def export_sources(self):
171-
copy(self, "CMakeLists.txt", src=self.recipe_folder, dst=self.export_sources_folder)
172-
copy(self, "src/*", src=self.recipe_folder, dst=self.export_sources_folder)
173-
copy(self, "cmake/*", src=self.recipe_folder, dst=self.export_sources_folder)
174-
"""
175-
176-
template = Template(dedent(template_string))
177-
178-
test_dependencies = dependency_groups.get('test', [])
179-
180-
values = {
181-
'name': name,
182-
'version': version,
183-
'dependencies': [dependency.requires() for dependency in dependencies],
184-
'test_requires': [dependency.requires() for dependency in test_dependencies],
185-
}
186-
187-
result = template.substitute(values)
188-
189-
with open(conan_file, 'w', encoding='utf-8') as file:
190-
file.write(result)
68+
"""Creates a conanfile.py file that inherits from CPPython base."""
69+
class_name = name.replace('-', '_').title().replace('_', '')
70+
content = f'''import os
71+
from conan.tools.cmake import CMake, CMakeDeps, CMakeToolchain, cmake_layout
72+
from conan.tools.files import copy
73+
74+
from conanfile_base import CPPythonBase
75+
76+
77+
class {class_name}Package(CPPythonBase):
78+
"""Conan recipe for {name}."""
79+
80+
name = "{name}"
81+
version = "{version}"
82+
settings = "os", "compiler", "build_type", "arch"
83+
exports = "conanfile_base.py"
84+
85+
def requirements(self):
86+
"""Declare package dependencies.
87+
88+
CPPython managed dependencies are inherited from CPPythonBase.
89+
Add your custom requirements here.
90+
"""
91+
super().requirements() # Get CPPython managed dependencies
92+
# Add your custom requirements here
93+
94+
def build_requirements(self):
95+
"""Declare build and test dependencies.
96+
97+
CPPython managed test dependencies are inherited from CPPythonBase.
98+
Add your custom build requirements here.
99+
"""
100+
super().build_requirements() # Get CPPython managed test dependencies
101+
# Add your custom build requirements here
102+
103+
def layout(self):
104+
cmake_layout(self)
105+
106+
def generate(self):
107+
deps = CMakeDeps(self)
108+
deps.generate()
109+
tc = CMakeToolchain(self)
110+
tc.user_presets_path = None
111+
tc.generate()
112+
113+
def build(self):
114+
cmake = CMake(self)
115+
cmake.configure()
116+
cmake.build()
117+
118+
def package(self):
119+
cmake = CMake(self)
120+
cmake.install()
121+
122+
def package_info(self):
123+
# Use native CMake config files to preserve FILE_SET information for C++ modules
124+
# This tells CMakeDeps to skip generating files and use the package's native config
125+
self.cpp_info.set_property("cmake_find_mode", "none")
126+
self.cpp_info.builddirs = ["."]
127+
128+
def export_sources(self):
129+
copy(self, "CMakeLists.txt", src=self.recipe_folder, dst=self.export_sources_folder)
130+
copy(self, "src/*", src=self.recipe_folder, dst=self.export_sources_folder)
131+
copy(self, "cmake/*", src=self.recipe_folder, dst=self.export_sources_folder)
132+
'''
133+
conan_file.write_text(content, encoding='utf-8')
191134

192135
def generate_conanfile(
193136
self,
@@ -197,17 +140,18 @@ def generate_conanfile(
197140
name: str,
198141
version: str,
199142
) -> None:
200-
"""Generate a conanfile.py file for the project."""
201-
conan_file = directory / self._filename
143+
"""Generate conanfile.py and conanfile_base.py for the project.
202144
203-
if conan_file.exists():
204-
source_code = conan_file.read_text(encoding='utf-8')
145+
Always generates the base conanfile with managed dependencies.
146+
Only creates conanfile.py if it doesn't exist (never modifies existing files).
147+
"""
148+
directory.mkdir(parents=True, exist_ok=True)
205149

206-
module = cst.parse_module(source_code)
207-
transformer = RequiresTransformer(dependencies)
208-
modified = module.visit(transformer)
150+
# Always regenerate the base conanfile with managed dependencies
151+
base_file = directory / 'conanfile_base.py'
152+
self._create_base_conanfile(base_file, dependencies, dependency_groups)
209153

210-
conan_file.write_text(modified.code, encoding='utf-8')
211-
else:
212-
directory.mkdir(parents=True, exist_ok=True)
213-
self._create_conanfile(conan_file, dependencies, dependency_groups, name, version)
154+
# Only create conanfile.py if it doesn't exist
155+
conan_file = directory / self._filename
156+
if not conan_file.exists():
157+
self._create_conanfile(conan_file, name, version)

pdm.lock

Lines changed: 2 additions & 33 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ pytest = ["pytest>=9.0.1", "pytest-mock>=3.15.1"]
2424
git = ["dulwich>=0.24.10"]
2525
pdm = ["pdm>=2.26.1"]
2626
cmake = ["cmake>=4.1.2"]
27-
conan = ["conan>=2.22.2", "libcst>=1.8.6"]
27+
conan = [
28+
"conan>=2.22.2",
29+
]
2830

2931
[project.urls]
3032
homepage = "https://github.com/Synodic-Software/CPPython"

0 commit comments

Comments
 (0)