diff --git a/.github/workflows/python-deploy-docs.yml b/.github/workflows/python-deploy-docs.yml index bdeaea48..a6ca751f 100644 --- a/.github/workflows/python-deploy-docs.yml +++ b/.github/workflows/python-deploy-docs.yml @@ -1,9 +1,10 @@ name: Publish Website to GitHub Pages on: push: + paths: + - docs/** branches: - - stable - - development + - "**" # Manual trigger workflow_dispatch: diff --git a/cppython/plugins/conan/builder.py b/cppython/plugins/conan/builder.py index 70ac9058..fc255ada 100644 --- a/cppython/plugins/conan/builder.py +++ b/cppython/plugins/conan/builder.py @@ -1,193 +1,136 @@ """Construction of Conan data""" from pathlib import Path -from string import Template -from textwrap import dedent -import libcst as cst from pydantic import DirectoryPath from cppython.plugins.conan.schema import ConanDependency -class RequiresTransformer(cst.CSTTransformer): - """Transformer to add or update the `requires` attribute in a ConanFile class.""" +class Builder: + """Aids in building the information needed for the Conan plugin""" - def __init__(self, dependencies: list[ConanDependency]) -> None: - """Initialize the transformer with a list of dependencies.""" - self.dependencies = dependencies + def __init__(self) -> None: + """Initialize the builder""" + self._filename = 'conanfile.py' - def _create_requires_assignment(self) -> cst.Assign: - """Create a `requires` assignment statement.""" - return cst.Assign( - targets=[cst.AssignTarget(cst.Name(value='requires'))], - value=cst.List( - [cst.Element(cst.SimpleString(f'"{dependency.requires()}"')) for dependency in self.dependencies] - ), - ) + @staticmethod + def _create_base_conanfile( + base_file: Path, + dependencies: list[ConanDependency], + dependency_groups: dict[str, list[ConanDependency]], + ) -> None: + """Creates a conanfile_base.py with CPPython managed dependencies.""" + test_dependencies = dependency_groups.get('test', []) - def leave_ClassDef(self, original_node: cst.ClassDef, updated_node: cst.ClassDef) -> cst.BaseStatement: - """Modify the class definition to include or update 'requires'. + # Generate requirements method content + requires_lines = [] + for dep in dependencies: + requires_lines.append(f' self.requires("{dep.requires()}")') + requires_content = '\n'.join(requires_lines) if requires_lines else ' pass # No requirements' + + # Generate build_requirements method content + test_requires_lines = [] + for dep in test_dependencies: + test_requires_lines.append(f' self.test_requires("{dep.requires()}")') + test_requires_content = ( + '\n'.join(test_requires_lines) if test_requires_lines else ' pass # No test requirements' + ) - Args: - original_node: The original class definition. - updated_node: The updated class definition. + content = f'''"""CPPython managed base ConanFile. - Returns: The modified class definition. - """ - if self._is_conanfile_class(original_node): - updated_node = self._update_requires(updated_node) - return updated_node +This file is auto-generated by CPPython. Do not edit manually. +Dependencies are managed through pyproject.toml. +""" - @staticmethod - def _is_conanfile_class(class_node: cst.ClassDef) -> bool: - """Check if the class inherits from ConanFile. +from conan import ConanFile - Args: - class_node: The class definition to check. - Returns: True if the class inherits from ConanFile, False otherwise. - """ - return any((isinstance(base.value, cst.Name) and base.value.value == 'ConanFile') for base in class_node.bases) - - def _update_requires(self, updated_node: cst.ClassDef) -> cst.ClassDef: - """Update or add a 'requires' assignment in a ConanFile class definition.""" - # Check if 'requires' is already defined - for body_statement_line in updated_node.body.body: - if not isinstance(body_statement_line, cst.SimpleStatementLine): - continue - for assignment_statement in body_statement_line.body: - if not isinstance(assignment_statement, cst.Assign): - continue - for target in assignment_statement.targets: - if not isinstance(target.target, cst.Name) or target.target.value != 'requires': - continue - # Replace only the assignment within the SimpleStatementLine - return self._replace_requires(updated_node, body_statement_line, assignment_statement) - - # Find the last attribute assignment before methods - last_attribute = None - for body_statement_line in updated_node.body.body: - if not isinstance(body_statement_line, cst.SimpleStatementLine): - break - if not body_statement_line.body: - break - if not isinstance(body_statement_line.body[0], cst.Assign): - break - last_attribute = body_statement_line - - # Construct a new statement for the 'requires' attribute - new_statement = cst.SimpleStatementLine( - body=[self._create_requires_assignment()], - ) +class CPPythonBase(ConanFile): + """Base ConanFile with CPPython managed dependencies.""" - # Insert the new statement after the last attribute assignment - if last_attribute is not None: - new_body = [item for item in updated_node.body.body] - index = new_body.index(last_attribute) - new_body.insert(index + 1, new_statement) - else: - new_body = [new_statement] + [item for item in updated_node.body.body] - return updated_node.with_changes(body=updated_node.body.with_changes(body=new_body)) - - def _replace_requires( - self, updated_node: cst.ClassDef, body_statement_line: cst.SimpleStatementLine, assignment_statement: cst.Assign - ) -> cst.ClassDef: - """Replace the existing 'requires' assignment with a new one, preserving other statements on the same line.""" - new_value = cst.List( - [cst.Element(cst.SimpleString(f'"{dependency.requires()}"')) for dependency in self.dependencies] - ) - new_assignment = assignment_statement.with_changes(value=new_value) - - # Replace only the relevant assignment in the SimpleStatementLine - new_body = [ - new_assignment if statement is assignment_statement else statement for statement in body_statement_line.body - ] - new_statement_line = body_statement_line.with_changes(body=new_body) - - # Replace the statement line in the class body - return updated_node.with_changes( - body=updated_node.body.with_changes( - body=[new_statement_line if item is body_statement_line else item for item in updated_node.body.body] - ) - ) + def requirements(self): + """CPPython managed requirements.""" +{requires_content} - -class Builder: - """Aids in building the information needed for the Conan plugin""" - - def __init__(self) -> None: - """Initialize the builder""" - self._filename = 'conanfile.py' + def build_requirements(self): + """CPPython managed build and test requirements.""" +{test_requires_content} +''' + base_file.write_text(content, encoding='utf-8') @staticmethod def _create_conanfile( conan_file: Path, - dependencies: list[ConanDependency], - dependency_groups: dict[str, list[ConanDependency]], name: str, version: str, ) -> None: - """Creates a conanfile.py file with the necessary content.""" - template_string = """ - import os - from conan import ConanFile - from conan.tools.cmake import CMake, CMakeDeps, CMakeToolchain, cmake_layout - from conan.tools.files import copy - - class AutoPackage(ConanFile): - name = "${name}" - version = "${version}" - settings = "os", "compiler", "build_type", "arch" - requires = ${dependencies} - test_requires = ${test_requires} - - def layout(self): - cmake_layout(self) - - def generate(self): - deps = CMakeDeps(self) - deps.generate() - tc = CMakeToolchain(self) - tc.user_presets_path = None - tc.generate() - - def build(self): - cmake = CMake(self) - cmake.configure() - cmake.build() - - def package(self): - cmake = CMake(self) - cmake.install() - - def package_info(self): - # Use native CMake config files to preserve FILE_SET information for C++ modules - # This tells CMakeDeps to skip generating files and use the package's native config - self.cpp_info.set_property("cmake_find_mode", "none") - self.cpp_info.builddirs = ["."] - - def export_sources(self): - copy(self, "CMakeLists.txt", src=self.recipe_folder, dst=self.export_sources_folder) - copy(self, "src/*", src=self.recipe_folder, dst=self.export_sources_folder) - copy(self, "cmake/*", src=self.recipe_folder, dst=self.export_sources_folder) - """ - - template = Template(dedent(template_string)) - - test_dependencies = dependency_groups.get('test', []) - - values = { - 'name': name, - 'version': version, - 'dependencies': [dependency.requires() for dependency in dependencies], - 'test_requires': [dependency.requires() for dependency in test_dependencies], - } - - result = template.substitute(values) - - with open(conan_file, 'w', encoding='utf-8') as file: - file.write(result) + """Creates a conanfile.py file that inherits from CPPython base.""" + class_name = name.replace('-', '_').title().replace('_', '') + content = f'''import os +from conan.tools.cmake import CMake, CMakeDeps, CMakeToolchain, cmake_layout +from conan.tools.files import copy + +from conanfile_base import CPPythonBase + + +class {class_name}Package(CPPythonBase): + """Conan recipe for {name}.""" + + name = "{name}" + version = "{version}" + settings = "os", "compiler", "build_type", "arch" + exports = "conanfile_base.py" + + def requirements(self): + """Declare package dependencies. + + CPPython managed dependencies are inherited from CPPythonBase. + Add your custom requirements here. + """ + super().requirements() # Get CPPython managed dependencies + # Add your custom requirements here + + def build_requirements(self): + """Declare build and test dependencies. + + CPPython managed test dependencies are inherited from CPPythonBase. + Add your custom build requirements here. + """ + super().build_requirements() # Get CPPython managed test dependencies + # Add your custom build requirements here + + def layout(self): + cmake_layout(self) + + def generate(self): + deps = CMakeDeps(self) + deps.generate() + tc = CMakeToolchain(self) + tc.user_presets_path = None + tc.generate() + + def build(self): + cmake = CMake(self) + cmake.configure() + cmake.build() + + def package(self): + cmake = CMake(self) + cmake.install() + + def package_info(self): + # Use native CMake config files to preserve FILE_SET information for C++ modules + # This tells CMakeDeps to skip generating files and use the package's native config + self.cpp_info.set_property("cmake_find_mode", "none") + self.cpp_info.builddirs = ["."] + + def export_sources(self): + copy(self, "CMakeLists.txt", src=self.recipe_folder, dst=self.export_sources_folder) + copy(self, "src/*", src=self.recipe_folder, dst=self.export_sources_folder) + copy(self, "cmake/*", src=self.recipe_folder, dst=self.export_sources_folder) +''' + conan_file.write_text(content, encoding='utf-8') def generate_conanfile( self, @@ -197,17 +140,18 @@ def generate_conanfile( name: str, version: str, ) -> None: - """Generate a conanfile.py file for the project.""" - conan_file = directory / self._filename + """Generate conanfile.py and conanfile_base.py for the project. - if conan_file.exists(): - source_code = conan_file.read_text(encoding='utf-8') + Always generates the base conanfile with managed dependencies. + Only creates conanfile.py if it doesn't exist (never modifies existing files). + """ + directory.mkdir(parents=True, exist_ok=True) - module = cst.parse_module(source_code) - transformer = RequiresTransformer(dependencies) - modified = module.visit(transformer) + # Always regenerate the base conanfile with managed dependencies + base_file = directory / 'conanfile_base.py' + self._create_base_conanfile(base_file, dependencies, dependency_groups) - conan_file.write_text(modified.code, encoding='utf-8') - else: - directory.mkdir(parents=True, exist_ok=True) - self._create_conanfile(conan_file, dependencies, dependency_groups, name, version) + # Only create conanfile.py if it doesn't exist + conan_file = directory / self._filename + if not conan_file.exists(): + self._create_conanfile(conan_file, name, version) diff --git a/pdm.lock b/pdm.lock index fa4cee18..dc401e96 100644 --- a/pdm.lock +++ b/pdm.lock @@ -2,10 +2,10 @@ # It is not intended for manual editing. [metadata] -groups = ["default", "cmake", "conan", "docs", "git", "lint", "pdm", "pytest", "release", "test"] +groups = ["default", "cmake", "conan", "docs", "git", "lint", "pdm", "pytest", "test"] strategy = [] lock_version = "4.5.0" -content_hash = "sha256:afd12e054b8dee77403a42e9e5da340cb042fb68f86e1f6c8b4f871e98eb1f06" +content_hash = "sha256:cfe62e7b9cbda1f29d79d097d852cb9282626dd6bd8bb9747e62069a686541d1" [[metadata.targets]] requires_python = ">=3.14" @@ -473,37 +473,6 @@ files = [ {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, ] -[[package]] -name = "libcst" -version = "1.8.6" -requires_python = ">=3.9" -summary = "A concrete syntax tree with AST-like properties for Python 3.0 through 3.14 programs." -dependencies = [ - "pyyaml-ft>=8.0.0; python_version == \"3.13\"", - "pyyaml>=5.2; python_version < \"3.13\"", - "pyyaml>=6.0.3; python_version >= \"3.14\"", - "typing-extensions; python_version < \"3.10\"", -] -files = [ - {file = "libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c"}, - {file = "libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661"}, - {file = "libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474"}, - {file = "libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8"}, - {file = "libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a"}, - {file = "libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47"}, - {file = "libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4"}, - {file = "libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9"}, - {file = "libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1"}, - {file = "libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4"}, - {file = "libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28"}, - {file = "libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa"}, - {file = "libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1"}, - {file = "libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996"}, - {file = "libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82"}, - {file = "libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f"}, - {file = "libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b"}, -] - [[package]] name = "markdown" version = "3.10" @@ -1055,7 +1024,7 @@ files = [ [[package]] name = "zensical" -version = "0.0.7" +version = "0.0.8" requires_python = ">=3.10" summary = "A modern static site generator built by the creators of Material for MkDocs" dependencies = [ @@ -1068,17 +1037,17 @@ dependencies = [ "tomli>=2.0; python_full_version < \"3.11\"", ] files = [ - {file = "zensical-0.0.7-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:895ef47444943b4b2dafd4b851fa5f759c1a45cf3df660f430b609a43e6d919d"}, - {file = "zensical-0.0.7-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:522d46093272c8a2f4b8909619228b1f25ebee88e1afe580a0ca706ebd8f9349"}, - {file = "zensical-0.0.7-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b2f0a461bd7bb94e7397b887267c1b334ad2c13a2f5214fe56ecf029130589"}, - {file = "zensical-0.0.7-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7d63e76f65cd95059d00323a647474ec1ba26cfb6f8ed66ee655416f6a42457"}, - {file = "zensical-0.0.7-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3714c3578083fedf242e37ddb3fac2fdfb1edb260b4b6965900d4b1290efaff2"}, - {file = "zensical-0.0.7-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:340e71e3237083f5c9fa814fbd845c8bf05e5005f86b95b8a153783f11e1a2da"}, - {file = "zensical-0.0.7-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:467c23873673a2fe2bbc5a6f1e0103a21de302fbcfc0d1f1e2f85a596fd9fc6d"}, - {file = "zensical-0.0.7-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:86f6ed4f8be7f33d236e56d892e33b24f0fe31063ea1e43d3ed30a0723376465"}, - {file = "zensical-0.0.7-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:1e9ef76eec5b0e7eed11d82b2da4cd474c76711f940cd3947e794dc8210c862c"}, - {file = "zensical-0.0.7-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:90049dea031e870c0381cad99c4108951e06a7526cdb13b36a78256feddb40f2"}, - {file = "zensical-0.0.7-cp310-abi3-win32.whl", hash = "sha256:2dca8951c4768f0484323254292e5db1c1bc61caac2e35196cae2b50a81cd08e"}, - {file = "zensical-0.0.7-cp310-abi3-win_amd64.whl", hash = "sha256:c42682b5611298bf13738253b0ee1b0054c115c4488ae2c14878871c1e3f801d"}, - {file = "zensical-0.0.7.tar.gz", hash = "sha256:4738903eba16a9bc0aa029cfb10e6670d8f031996691a3a9f4a754c49da96e6f"}, + {file = "zensical-0.0.8-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:154235ea6ec00bb946652f63f58045bb38fa08b317841c274eba03bdf11fdba5"}, + {file = "zensical-0.0.8-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:7939a7ced996d536beed7bfafb1cb4f2f67aec52fa4aee0121575b8a32dcbad6"}, + {file = "zensical-0.0.8-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1987e2a069e6e4ac70e56d9cf8b7de3ee325626baa492a17cb43b756d68251e"}, + {file = "zensical-0.0.8-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1aa978af5137dd787b4c5bf7c877499c13a2e382edf1abefe7633f941ed0ab46"}, + {file = "zensical-0.0.8-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:844cfbeb18cbada83a15b262279fea5a784cf77a50e0026718d9bf5c6a40c584"}, + {file = "zensical-0.0.8-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee25c8cab072895be2411a4a218af32bf26af204b08fa766ac8c9e97f32a4699"}, + {file = "zensical-0.0.8-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68454a31dad461658e02c595291087c7fb3deeb2692a37ea1a1ab2e774ff5e96"}, + {file = "zensical-0.0.8-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:84dc551239f4d54eb236f7795e73356545daa6e946bedf4888dbc4028057363b"}, + {file = "zensical-0.0.8-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f4d8948a852d0ad55c6665fcdd4f5f3a77ccfb1c6c5eefa7b674c72ff560363e"}, + {file = "zensical-0.0.8-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:74688b2a85edfdc84d83610729b582d8437884fccd0c2554df20473156b121ca"}, + {file = "zensical-0.0.8-cp310-abi3-win32.whl", hash = "sha256:f6c0e3130d1d7e497e30378f97438af10b20d5c4943ebc74f2e9fd30465460b3"}, + {file = "zensical-0.0.8-cp310-abi3-win_amd64.whl", hash = "sha256:3a363363c3ab6683898344076290b89f873f0caaf4912de1f2de2052b1e01199"}, + {file = "zensical-0.0.8.tar.gz", hash = "sha256:756e05278834b867c359000b0c7215df8925527a7c050e7362cd77373028a3ef"}, ] diff --git a/pyproject.toml b/pyproject.toml index 90007dcd..86c4a74d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,9 @@ pytest = ["pytest>=9.0.1", "pytest-mock>=3.15.1"] git = ["dulwich>=0.24.10"] pdm = ["pdm>=2.26.1"] cmake = ["cmake>=4.1.2"] -conan = ["conan>=2.22.2", "libcst>=1.8.6"] +conan = [ + "conan>=2.22.2", +] [project.urls] homepage = "https://github.com/Synodic-Software/CPPython" @@ -49,7 +51,7 @@ cppython = "cppython.test.pytest.fixtures" [dependency-groups] lint = ["ruff>=0.14.5", "pyrefly>=0.41.3"] test = ["pytest>=9.0.1", "pytest-cov>=7.0.0", "pytest-mock>=3.15.1"] -docs = ["zensical>=0.0.7"] +docs = ["zensical>=0.0.8"] [project.scripts] cppython = "cppython.console.entry:app" diff --git a/tests/unit/plugins/conan/test_ast.py b/tests/unit/plugins/conan/test_ast.py deleted file mode 100644 index 591bbfaa..00000000 --- a/tests/unit/plugins/conan/test_ast.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Tests for the AST transformer that modifies ConanFile classes.""" - -import ast -from textwrap import dedent - -import libcst as cst - -from cppython.plugins.conan.builder import RequiresTransformer -from cppython.plugins.conan.schema import ConanDependency - - -class TestTransformer: - """Unit tests for the RequiresTransformer.""" - - class MockDependency(ConanDependency): - """A dummy dependency class for testing.""" - - @staticmethod - def requires() -> str: - """Return a dummy requires string.""" - return 'test/1.2.3' - - @staticmethod - def test_add_requires_when_missing() -> None: - """Test that the transformer adds requires when missing.""" - dependency = TestTransformer.MockDependency(name='test') - - code = """ - class MyFile(ConanFile): - name = "test" - version = "1.0" - """ - - module = cst.parse_module(dedent(code)) - transformer = RequiresTransformer([dependency]) - modified = module.visit(transformer) - assert 'requires = ["test/1.2.3"]' in modified.code - - # Verify the resulting code is valid Python syntax - ast.parse(modified.code) - - @staticmethod - def test_replace_existing_requires() -> None: - """Test that the transformer replaces existing requires.""" - dependency = TestTransformer.MockDependency(name='test') - - code = """ - class MyFile(ConanFile): - name = "test" - requires = ["old/0.1"] - version = "1.0" - """ - - module = cst.parse_module(dedent(code)) - transformer = RequiresTransformer([dependency]) - modified = module.visit(transformer) - assert 'requires = ["test/1.2.3"]' in modified.code - assert 'old/0.1' not in modified.code - - # Verify the resulting code is valid Python syntax - ast.parse(modified.code) - - @staticmethod - def test_no_conanfile_class() -> None: - """Test that the transformer does not modify non-ConanFile classes.""" - dependency = TestTransformer.MockDependency(name='test') - - code = """ - class NotConan: - pass - """ - - module = cst.parse_module(dedent(code)) - transformer = RequiresTransformer([dependency]) - modified = module.visit(transformer) - # Should not add requires to non-ConanFile classes - assert 'requires' not in modified.code - - # Verify the resulting code is valid Python syntax - ast.parse(modified.code) diff --git a/tests/unit/plugins/conan/test_builder.py b/tests/unit/plugins/conan/test_builder.py new file mode 100644 index 00000000..8d584fe0 --- /dev/null +++ b/tests/unit/plugins/conan/test_builder.py @@ -0,0 +1,158 @@ +"""Unit tests for Conan builder functionality.""" + +from pathlib import Path +from textwrap import dedent + +import pytest + +from cppython.plugins.conan.builder import Builder +from cppython.plugins.conan.schema import ConanDependency, ConanVersion + + +class TestBuilder: + """Test the Conan Builder class.""" + + @pytest.fixture + def builder(self) -> Builder: + """Create a Builder instance for testing.""" + return Builder() + + def test_mixed_dependencies(self, builder: Builder, tmp_path: Path) -> None: + """Test base conanfile with both regular and test dependencies.""" + base_file = tmp_path / 'conanfile_base.py' + + dependencies = [ + ConanDependency(name='boost', version=ConanVersion.from_string('1.80.0')), + ] + dependency_groups = { + 'test': [ + ConanDependency(name='gtest', version=ConanVersion.from_string('1.14.0')), + ] + } + + builder._create_base_conanfile(base_file, dependencies, dependency_groups) + + assert base_file.exists() + content = base_file.read_text(encoding='utf-8') + assert 'self.requires("boost/1.80.0")' in content + assert 'self.test_requires("gtest/1.14.0")' in content + + def test_creates_both_files(self, builder: Builder, tmp_path: Path) -> None: + """Test generate_conanfile creates both base and user files.""" + dependencies = [ + ConanDependency(name='boost', version=ConanVersion.from_string('1.80.0')), + ] + dependency_groups = {} + + builder.generate_conanfile( + directory=tmp_path, + dependencies=dependencies, + dependency_groups=dependency_groups, + name='test-project', + version='1.0.0', + ) + + base_file = tmp_path / 'conanfile_base.py' + conan_file = tmp_path / 'conanfile.py' + + assert base_file.exists() + assert conan_file.exists() + + def test_regenerates_base_file(self, builder: Builder, tmp_path: Path) -> None: + """Test base file is always regenerated with new dependencies.""" + dependencies_v1 = [ + ConanDependency(name='boost', version=ConanVersion.from_string('1.80.0')), + ] + + builder.generate_conanfile( + directory=tmp_path, + dependencies=dependencies_v1, + dependency_groups={}, + name='test-project', + version='1.0.0', + ) + + base_file = tmp_path / 'conanfile_base.py' + content_v1 = base_file.read_text(encoding='utf-8') + assert 'boost/1.80.0' in content_v1 + + dependencies_v2 = [ + ConanDependency(name='zlib', version=ConanVersion.from_string('1.2.13')), + ] + + builder.generate_conanfile( + directory=tmp_path, + dependencies=dependencies_v2, + dependency_groups={}, + name='test-project', + version='1.0.0', + ) + + content_v2 = base_file.read_text(encoding='utf-8') + assert 'zlib/1.2.13' in content_v2 + assert 'boost/1.80.0' not in content_v2 + + def test_preserves_user_file(self, builder: Builder, tmp_path: Path) -> None: + """Test user conanfile is never modified once created.""" + conan_file = tmp_path / 'conanfile.py' + custom_content = dedent(""" + from conanfile_base import CPPythonBase + + class CustomPackage(CPPythonBase): + name = "custom" + version = "1.0.0" + + def requirements(self): + super().requirements() + self.requires("custom-lib/1.0.0") + """) + conan_file.write_text(custom_content) + + dependencies = [ + ConanDependency(name='boost', version=ConanVersion.from_string('1.80.0')), + ] + + builder.generate_conanfile( + directory=tmp_path, + dependencies=dependencies, + dependency_groups={}, + name='new-name', + version='2.0.0', + ) + + final_content = conan_file.read_text() + assert final_content == custom_content + assert 'CustomPackage' in final_content + assert 'custom-lib/1.0.0' in final_content + + def test_inheritance_chain(self, builder: Builder, tmp_path: Path) -> None: + """Test complete inheritance chain from base to user file.""" + dependencies = [ + ConanDependency(name='boost', version=ConanVersion.from_string('1.80.0')), + ConanDependency(name='zlib', version=ConanVersion.from_string('1.2.13')), + ] + dependency_groups = { + 'test': [ + ConanDependency(name='gtest', version=ConanVersion.from_string('1.14.0')), + ] + } + + builder.generate_conanfile( + directory=tmp_path, + dependencies=dependencies, + dependency_groups=dependency_groups, + name='test-project', + version='1.0.0', + ) + + base_content = (tmp_path / 'conanfile_base.py').read_text(encoding='utf-8') + user_content = (tmp_path / 'conanfile.py').read_text(encoding='utf-8') + + assert 'self.requires("boost/1.80.0")' in base_content + assert 'self.requires("zlib/1.2.13")' in base_content + assert 'self.test_requires("gtest/1.14.0")' in base_content + + assert 'from conanfile_base import CPPythonBase' in user_content + assert 'class TestProjectPackage(CPPythonBase):' in user_content + assert 'super().requirements()' in user_content + assert 'super().build_requirements()' in user_content