Skip to content

Commit b27046d

Browse files
committed
Conanfile AST Injection
1 parent 57cf5e8 commit b27046d

File tree

2 files changed

+133
-4
lines changed

2 files changed

+133
-4
lines changed

cppython/plugins/conan/builder.py

+113-4
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,118 @@
44
from string import Template
55
from textwrap import dedent
66

7+
import libcst as cst
78
from pydantic import DirectoryPath
89

910
from cppython.plugins.conan.schema import ConanDependency
1011

1112

13+
class RequiresTransformer(cst.CSTTransformer):
14+
"""Transformer to add or update the `requires` attribute in a ConanFile class."""
15+
16+
def __init__(self, dependencies: list[ConanDependency]) -> None:
17+
"""Initialize the transformer with a list of dependencies."""
18+
self.dependencies = dependencies
19+
20+
def _create_requires_assignment(self) -> cst.Assign:
21+
"""Create a `requires` assignment statement."""
22+
return cst.Assign(
23+
targets=[cst.AssignTarget(cst.Name('requires'))],
24+
value=cst.List([
25+
cst.Element(cst.SimpleString(f'"{dependency.requires()}"')) for dependency in self.dependencies
26+
]),
27+
)
28+
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'.
31+
32+
Args:
33+
original_node: The original class definition.
34+
updated_node: The updated class definition.
35+
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
41+
42+
@staticmethod
43+
def _is_conanfile_class(class_node: cst.ClassDef) -> bool:
44+
"""Check if the class inherits from ConanFile.
45+
46+
Args:
47+
class_node: The class definition to check.
48+
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+
60+
assignment_statement = body_statement_line.body[0]
61+
if not isinstance(assignment_statement, cst.Assign):
62+
continue
63+
64+
for target in assignment_statement.targets:
65+
if not isinstance(target.target, cst.Name) or target.target.value != 'requires':
66+
continue
67+
68+
return self._replace_requires(updated_node, body_statement_line, assignment_statement)
69+
70+
# Find the last attribute assignment before methods
71+
last_attribute = None
72+
for body_statement_line in updated_node.body.body:
73+
if not isinstance(body_statement_line, cst.SimpleStatementLine):
74+
break
75+
assignment_statement = body_statement_line.body[0]
76+
if not isinstance(assignment_statement, cst.Assign):
77+
break
78+
last_attribute = body_statement_line
79+
80+
# Construct a new statement for the 'requires' attribute
81+
new_statement = cst.SimpleStatementLine(
82+
body=[self._create_requires_assignment()],
83+
)
84+
85+
# Insert the new statement after the last attribute assignment
86+
if last_attribute is not None:
87+
new_body = list(updated_node.body.body)
88+
index = new_body.index(last_attribute)
89+
new_body.insert(index + 1, new_statement)
90+
else:
91+
new_body = [new_statement] + list(updated_node.body.body)
92+
93+
return updated_node.with_changes(body=updated_node.body.with_changes(body=new_body))
94+
95+
def _replace_requires(
96+
self, updated_node: cst.ClassDef, body_statement_line: cst.SimpleStatementLine, assignment_statement: cst.Assign
97+
) -> cst.ClassDef:
98+
"""Replace the existing 'requires' assignment with a new one.
99+
100+
Args:
101+
updated_node (cst.ClassDef): The class definition to update.
102+
body_statement_line (cst.SimpleStatementLine): The body item containing the assignment.
103+
assignment_statement (cst.Assign): The existing assignment statement.
104+
105+
Returns:
106+
cst.ClassDef: The updated class definition.
107+
"""
108+
new_value = cst.List([
109+
cst.Element(cst.SimpleString(f'"{dependency.requires()}"')) for dependency in self.dependencies
110+
])
111+
new_assignment = assignment_statement.with_changes(value=new_value)
112+
return updated_node.with_changes(
113+
body=updated_node.body.with_changes(
114+
body=[new_assignment if item is body_statement_line else item for item in updated_node.body.body]
115+
)
116+
)
117+
118+
12119
class Builder:
13120
"""Aids in building the information needed for the Conan plugin"""
14121

@@ -57,12 +164,14 @@ def generate_conanfile(self, directory: DirectoryPath, dependencies: list[ConanD
57164
"""Generate a conanfile.py file for the project."""
58165
conan_file = directory / self._filename
59166

60-
# If the file exists then we need to inject our information into it
61167
if conan_file.exists():
62-
raise NotImplementedError(
63-
'Updating existing conanfile.py is not yet supported. Please remove the file and try again.'
64-
)
168+
source_code = conan_file.read_text(encoding='utf-8')
169+
170+
module = cst.parse_module(source_code)
171+
transformer = RequiresTransformer(dependencies)
172+
modified = module.visit(transformer)
65173

174+
conan_file.write_text(modified.code, encoding='utf-8')
66175
else:
67176
directory.mkdir(parents=True, exist_ok=True)
68177
self._create_conanfile(conan_file, dependencies)

tests/integration/examples/test_conan_cmake.py

+20
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,23 @@ def test_simple(example_runner: CliRunner) -> None:
3636

3737
# Verify that the build directory contains the expected files
3838
assert (Path('build') / 'CMakeCache.txt').exists(), 'build/CMakeCache.txt not found'
39+
40+
@staticmethod
41+
def test_inject(example_runner: CliRunner) -> None:
42+
"""Inject"""
43+
result = example_runner.invoke(
44+
app,
45+
[
46+
'install',
47+
],
48+
)
49+
50+
assert result.exit_code == 0, result.output
51+
52+
# Run the CMake configuration command
53+
cmake_result = subprocess.run(['cmake', '--preset=default'], capture_output=True, text=True, check=False)
54+
55+
assert cmake_result.returncode == 0, f'CMake configuration failed: {cmake_result.stderr}'
56+
57+
# Verify that the build directory contains the expected files
58+
assert (Path('build') / 'CMakeCache.txt').exists(), 'build/CMakeCache.txt not found'

0 commit comments

Comments
 (0)