diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index a8e6ab93f..676564e56 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -206,10 +206,10 @@ jobs: pip install --upgrade pip pip install .[doc] - - name: "Retrieve pyconverter.xml2py version" + - name: "Retrieve PyConverter.XML2Py version" run: | echo "PYCONVERTER_VERSION=$(python -c 'from pyconverter.xml2py import __version__; print(__version__)')" >> $GITHUB_ENV - echo "pyconverter.xml2py version is: $(python -c 'from pyconverter.xml2py import __version__; print(__version__)')" + echo "PyConverter.XML2Py version is: $(python -c 'from pyconverter.xml2py import __version__; print(__version__)')" - name: "Cache docs build directory" uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.2.3 # zizmor: ignore[cache-poisoning] diff --git a/_package/doc/source/conf.py b/_package/doc/source/conf.py index b90de832a..e7ff59e66 100644 --- a/_package/doc/source/conf.py +++ b/_package/doc/source/conf.py @@ -79,7 +79,7 @@ numpydoc_xref_param_type = True numpydoc_validate = True numpydoc_validation_checks = { - "GL06", # Found unknown section + # "GL06", # Found unknown section - commenting due to ``Command Specifications`` section "GL07", # Sections are in the wrong order. # "GL08", # The object does not have a docstring "GL09", # Deprecation warning should precede extended summary diff --git a/config.yaml b/config.yaml index 4a5301b36..a5a379f58 100644 --- a/config.yaml +++ b/config.yaml @@ -79,4 +79,14 @@ comments: specific_classes: 2D to 3D Analysis: Analysis 2D to 3D - Parameters: Parameter definition \ No newline at end of file + Parameters: Parameter definition + +base_class: + # Pattern-based inheritance rules + # Patterns are matched against "module_name/class_name" + # Use "*" for wildcards, e.g., "apdl/*" matches all classes in apdl module + # Rules are evaluated in order - first match wins + rules: + - pattern: "*" + module: "ansys.mapdl.core._commands" + class_name: "CommandsBase" \ No newline at end of file diff --git a/doc/source/user_guide/configuration.rst b/doc/source/user_guide/configuration.rst new file mode 100644 index 000000000..3c3a3bbc0 --- /dev/null +++ b/doc/source/user_guide/configuration.rst @@ -0,0 +1,414 @@ +.. _ref_configuration: + +Configuration reference +======================= + +The ``config.yaml`` file is the central configuration file for PyConverter-XML2Py. +It controls various aspects of code generation, including project structure, +command mapping, and class inheritance. + +This section provides a comprehensive reference for all available configuration options. + + +File location +------------- + +The ``config.yaml`` file should be located in the root directory of your project, +alongside the converter executable. + + +Configuration options +--------------------- + + +Project metadata +~~~~~~~~~~~~~~~~ + +``project_name`` +^^^^^^^^^^^^^^^^ + +**Type:** String + +**Description:** Name of the generated project. + +**Example:** + +.. code-block:: yaml + + project_name: PyConverter-GeneratedCommands + + +``library_name_structured`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Type:** List of strings + +**Description:** Defines the nested package structure for the generated library. +The converter creates a directory hierarchy based on this list. + +**Example:** + +.. code-block:: yaml + + library_name_structured: + - pyconverter + - generatedcommands + +This generates the structure: ``src/pyconverter/generatedcommands/`` + + +``subfolders`` +^^^^^^^^^^^^^^ + +**Type:** List of strings + +**Description:** Additional subfolders to create within the library structure. +Useful for organizing generated code into deeper hierarchies. + +**Example:** + +.. code-block:: yaml + + subfolders: + - subfolder + - subsubfolder + +This adds: ``src/pyconverter/generatedcommands/subfolder/subsubfolder/`` + + +``new_package_name`` +^^^^^^^^^^^^^^^^^^^^ + +**Type:** String + +**Description:** Name of the directory where the generated package is created. + +**Default:** ``package`` + +**Example:** + +.. code-block:: yaml + + new_package_name: package + + +``image_folder_path`` +^^^^^^^^^^^^^^^^^^^^^ + +**Type:** String + +**Description:** Relative path where command images are stored in the generated +documentation. + +**Example:** + +.. code-block:: yaml + + image_folder_path: ../images/_commands + + +Command name mapping +~~~~~~~~~~~~~~~~~~~~ + +``rules`` +^^^^^^^^^ + +**Type:** Dictionary (key-value pairs) + +**Description:** Defines character replacement rules for converting command names +to valid Python identifiers. Useful for handling special characters in command names. + +**Example:** + +.. code-block:: yaml + + rules: + "/": slash + "*": star + +- Command ``/PREP7`` becomes ``prep7()`` +- Command ``*GET`` becomes ``starget()`` or ``get()`` (depending on additional rules) + + +``specific_command_mapping`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Type:** Dictionary (key-value pairs) + +**Description:** Explicitly maps specific command names to Python function names. +Takes precedence over general rules. + +**Example:** + +.. code-block:: yaml + + specific_command_mapping: + "*DEL": stardel + "C***": c + "/INQUIRE": inquire + + +Command filtering +~~~~~~~~~~~~~~~~~ + +``ignored_commands`` +^^^^^^^^^^^^^^^^^^^^ + +**Type:** List of strings + +**Description:** Commands to exclude from code generation. Useful for commands +that are already implemented elsewhere or should not be converted. + +**Example:** + +.. code-block:: yaml + + ignored_commands: + - "*ASK" + - "*VEDIT" + - "/ERASE" + - "HELP" + - "/EXIT" + +Commands in this list are skipped during conversion and do not generate Python methods. + + +Documentation annotations +~~~~~~~~~~~~~~~~~~~~~~~~~ + +``comments`` +^^^^^^^^^^^^ + +**Type:** List of comment objects + +**Description:** Adds custom messages to specific command documentation. Each comment +object has three fields: + +- ``msg``: The message text (supports reStructuredText formatting) +- ``type``: Message type (``"warning"``, ``"note"``, ``"tip"``, etc.) +- ``commands``: List of commands to annotate + +**Example:** + +.. code-block:: yaml + + comments: + - msg: 'This command must be run using :func:`non_interactive`.' + type: "warning" + commands: + - "*CREATE" + - "CFOPEN" + - "*VWRITE" + + - msg: 'Starting with v0.66.0, you can use "P" for interactive selection.' + type: "note" + commands: + - "ASEL" + +The message appears as a Sphinx admonition in the generated documentation: + +.. warning:: + This command must be run using :func:`non_interactive`. + + +Class organization +~~~~~~~~~~~~~~~~~~ + +``specific_classes`` +^^^^^^^^^^^^^^^^^^^^ + +**Type:** Dictionary (key-value pairs) + +**Description:** Renames classes generated from command groups. By default, classes +are named based on the command group name, but this option allows customization. + +**Example:** + +.. code-block:: yaml + + specific_classes: + 2D to 3D Analysis: Analysis 2D to 3D + Parameters: Parameter definition + +- The class for "2D to 3D Analysis" commands is named ``Analysis2Dto3D`` +- The class for "Parameters" commands is named ``ParameterDefinition`` + + +Base class inheritance +~~~~~~~~~~~~~~~~~~~~~~ + +``base_class`` +^^^^^^^^^^^^^^ + +**Type:** Object with ``rules`` list + +**Description:** Configures pattern-based inheritance for generated classes. This +allows generated classes to inherit from a base class. + +**Structure:** + +.. code-block:: yaml + + base_class: + rules: + - pattern: "pattern_string" + module: "module.path" + class_name: "BaseClassName" + +**Fields:** + +- ``pattern``: Glob pattern to match against ``"module_name/class_name"`` +- ``module``: Python module path to import the base class from +- ``class_name``: Name of the base class to inherit from + +**Pattern matching:** + +Patterns are matched against the full path ``"module_name/class_name"`` and support +wildcards: + +- ``"*"`` - Matches all classes +- ``"module_name/*"`` - Matches all classes in a specific module +- ``"module_name/ClassName"`` - Matches a specific class +- ``"*/ClassName"`` - Matches any class named ``ClassName`` in any module + +**Rule evaluation:** Rules are evaluated in order, and the **first matching rule wins**. +This allows you to define specific overrides followed by general fallback patterns. + +**Example 1: All classes inherit from the same base** + +.. code-block:: yaml + + base_class: + rules: + - pattern: "*" + module: "ansys.mapdl.core" + class_name: "BaseCommandClass" + +Generates: + +.. code-block:: python + + from ansys.mapdl.core import BaseCommandClass + + + class Abbreviations(BaseCommandClass): + def method(self): + pass + +**Example 2: Module-specific inheritance** + +.. code-block:: yaml + + base_class: + rules: + - pattern: "apdl/*" + module: "ansys.mapdl.core.apdl" + class_name: "APDLBase" + + - pattern: "prep7/*" + module: "ansys.mapdl.core.prep" + class_name: "PrepBase" + +**Example 3: Specific class with fallback** + +.. code-block:: yaml + + base_class: + rules: + - pattern: "prep7/Meshing" + module: "ansys.mapdl.core.prep.special" + class_name: "SpecialMeshingBase" + + - pattern: "prep7/*" + module: "ansys.mapdl.core.prep" + class_name: "PrepBase" + + - pattern: "*" + module: "ansys.mapdl.core" + class_name: "BaseCommandClass" + +In this example: + +- ``prep7/Meshing`` gets ``SpecialMeshingBase`` +- Other ``prep7`` classes get ``PrepBase`` +- All other classes get ``BaseCommandClass`` + +**Default behavior:** If no ``base_class`` configuration is provided or no pattern +matches, classes are generated without inheritance: + +.. code-block:: python + + class Abbreviations: + def method(self): + pass + + +Complete example +---------------- + +Here is a complete ``config.yaml`` file showing all options: + +.. code-block:: yaml + + # Project metadata + project_name: PyConverter-GeneratedCommands + + library_name_structured: + - pyconverter + - generatedcommands + + subfolders: + - subfolder + - subsubfolder + + new_package_name: package + + image_folder_path: ../images/_commands + + # Command name mapping + rules: + "/": slash + "*": star + + specific_command_mapping: + "*DEL": stardel + "C***": c + "/INQUIRE": inquire + + # Command filtering + ignored_commands: + - "*ASK" + - "*VEDIT" + - "/ERASE" + - "HELP" + - "/EXIT" + + # Documentation annotations + comments: + - msg: 'This command requires special handling.' + type: "warning" + commands: + - "*CREATE" + - "*VWRITE" + + # Class organization + specific_classes: + 2D to 3D Analysis: Analysis 2D to 3D + Parameters: Parameter definition + + # Base class inheritance + base_class: + rules: + - pattern: "prep7/Meshing" + module: "ansys.mapdl.core.prep" + class_name: "PrepBase" + + - pattern: "apdl/*" + module: "ansys.mapdl.core.apdl" + class_name: "APDLBase" + + - pattern: "*" + module: "ansys.mapdl.core" + class_name: "BaseCommandClass" + + diff --git a/doc/source/user_guide/index.rst b/doc/source/user_guide/index.rst index 961e2b37f..46ecf72c2 100644 --- a/doc/source/user_guide/index.rst +++ b/doc/source/user_guide/index.rst @@ -42,6 +42,24 @@ Python files: For more information, see :ref:`ref_source_code`. + +Configuration options +--------------------- + +The converter behavior can be customized through the ``config.yaml`` file, which +allows you to configure: + +- Project metadata and package structure +- Command name mapping and filtering +- Documentation annotations +- Class organization and inheritance + +For detailed information about all available configuration options, see :ref:`ref_configuration`. + + +Generate documentation +---------------------- + After the converter runs, you can generate Sphinx documentation. This code renders the documentation as HTML from Windows: @@ -69,5 +87,6 @@ directory by default. This diagram presents the format of the .. toctree:: :maxdepth: 1 + configuration source_code objects \ No newline at end of file diff --git a/doc/styles/config/vocabularies/ANSYS/accept.txt b/doc/styles/config/vocabularies/ANSYS/accept.txt index 271e5c669..74bb689cd 100644 --- a/doc/styles/config/vocabularies/ANSYS/accept.txt +++ b/doc/styles/config/vocabularies/ANSYS/accept.txt @@ -2,6 +2,7 @@ ANSYS Ansys ansys API +AST autogenerated Autogenerated Docbook @@ -9,8 +10,8 @@ docstring isort Makefile PyConverter-XML2Py -AST pytest +subfolders venv XML XML_directory diff --git a/make_package_doc.ps1 b/make_package_doc.ps1 index 56c63d45e..5b77f3af1 100644 --- a/make_package_doc.ps1 +++ b/make_package_doc.ps1 @@ -7,7 +7,7 @@ function Green deactivate cd .\package\ -uv venv .venv --seed +uv venv .venv --seed --python 3.12 .\.venv\Scripts\activate Write-Output "A new virtual environment has been created within the package folder." | Green uv pip install -e .[doc] diff --git a/src/pyconverter/xml2py/__init__.py b/src/pyconverter/xml2py/__init__.py index f51e0ea48..84be8ab11 100644 --- a/src/pyconverter/xml2py/__init__.py +++ b/src/pyconverter/xml2py/__init__.py @@ -21,7 +21,7 @@ # SOFTWARE. """ -pyconverter.xml2py +PyConverter.XML2Py """ try: @@ -30,4 +30,4 @@ import importlib_metadata __version__ = importlib_metadata.version(__name__.replace(".", "-")) -"""pyconverter.xml2py version.""" +"""PyConverter.XML2Py version.""" diff --git a/src/pyconverter/xml2py/ast_tree.py b/src/pyconverter/xml2py/ast_tree.py index c10618707..03a10927f 100644 --- a/src/pyconverter/xml2py/ast_tree.py +++ b/src/pyconverter/xml2py/ast_tree.py @@ -1774,11 +1774,22 @@ def tail(self): """Tail of the element as a string.""" return " ".join([str(item) for item in self._content]) - def to_rst(self, indent="", max_length=100): + def to_rst(self, indent="", max_length=100, links=None, base_url=None): """Return a string to enable converting the element to an RST format.""" - # internal links - linkend = (self.linkend).replace(".", "_") - return f":ref:`{linkend}` {self.tail}" + if (links or base_url) is None: + logger.error( + "ERROR exists in the links or the 'base_url' definitions in the 'Link' class." + ) + if self.linkend in links: + root_name, root_title, href, text = links[self.linkend] + text = text.replace("\n", "") + link = f"{base_url}{root_name}/{href}" + output = f"`{text} <{link}>`_ {self.tail}" + else: + # internal links + linkend = (self.linkend).replace(".", "_") + output = f":ref:`{linkend}` {self.tail}" + return output class UserInput(ProgramListing): @@ -3354,12 +3365,19 @@ def py_docstring( automated_notes = self.py_notes(self.notes, "Notes") custom_notes = self.custom_notes(custom_functions, automated_notes) if not custom_notes: - if self.other_parameters: - items += [""] - items.extend(self.py_notes(self.other_parameters, "Other Parameters")) if self.notes: items += [""] items.extend(automated_notes) + if self.other_parameters: + items += [""] + items.extend( + self.py_notes(self.other_parameters, "Command Specifications", "~") + ) + elif self.other_parameters: + items += [""] + items += ["Notes", "-" * len("Notes")] + items.extend(self.py_notes(self.other_parameters, "Command Specifications", "~")) + else: items.extend(custom_notes) if custom_functions and ( @@ -3540,9 +3558,9 @@ def trail_replacer(match): docstr = replace_terms(docstr, self._terms) return docstr - def py_notes(self, note_elem_list, section_title): + def py_notes(self, note_elem_list, section_title, title_style="-"): """Python-formatted notes string.""" - lines = [section_title, "-" * len(section_title)] + lines = [section_title, title_style * len(section_title)] if section_title == "Notes" and self._is_paragraph_in_arg_desc: if not self.url: # Check if self.url is valid raise ValueError("The 'url' property is not properly initialized.") @@ -3908,6 +3926,7 @@ def to_rst(self, indent="", max_length=100): "footnote": Footnote, "warning": XMLWarning, "caution": Caution, + "xref": XRef, } item_needing_fcache = { diff --git a/src/pyconverter/xml2py/utils/utils.py b/src/pyconverter/xml2py/utils/utils.py index 67a0b28fd..01c38c293 100644 --- a/src/pyconverter/xml2py/utils/utils.py +++ b/src/pyconverter/xml2py/utils/utils.py @@ -20,9 +20,10 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +import fnmatch import logging from pathlib import Path -from typing import Tuple, Union +from typing import Dict, Optional, Tuple, Union from lxml.html import fromstring import yaml @@ -68,6 +69,92 @@ def get_config_data_value(yaml_path: Path, value: str) -> Union[str, dict, list, return config_data.get(value) +def get_base_class_config(config_path: Path) -> dict: + """ + Get base class configuration from config file. + + Parameters + ---------- + config_path: Path + Path object of the configuration YAML file. + + Returns + ------- + dict + Base class configuration dictionary with 'rules' key, or empty dict if not configured. + """ + base_class_config = get_config_data_value(config_path, "base_class") + if base_class_config is None: + return {} + return base_class_config + + +def get_base_class_for_pattern( + config_path: Path, module_name: str, class_name: str +) -> Optional[Dict[str, str]]: + """ + Determine if a class should inherit based on pattern matching. + + Patterns are matched against "module_name/class_name" format. + First matching rule wins. + + Parameters + ---------- + config_path: Path + Path object of the configuration YAML file. + module_name: str + Module name (e.g., "apdl", "prep7"). + class_name: str + Class name (e.g., "Abbreviations", "Meshing"). + + Returns + ------- + dict or None + Dictionary with 'module' and 'class_name' keys if inheritance should be applied, + None if no pattern matches. + + Examples + -------- + >>> get_base_class_for_pattern(config_path, "apdl", "Abbreviations") + {'module': 'ansys.mapdl.core', 'class_name': 'BaseCommandClass'} + >>> get_base_class_for_pattern(config_path, "database", "Save") + None + """ + base_class_config = get_base_class_config(config_path) + + # If no base_class config or no rules, return None + if not base_class_config or "rules" not in base_class_config: + return None + + rules = base_class_config.get("rules", []) + if not rules: + return None + + # Construct the full path for matching: "module_name/class_name" + full_path = f"{module_name}/{class_name}" + + # Iterate through rules and find first match + for rule in rules: + if not isinstance(rule, dict): + continue + + pattern = rule.get("pattern") + if not pattern: + continue + + # Use fnmatch for wildcard pattern matching + if fnmatch.fnmatch(full_path, pattern): + # Found a match, return the base class info + base_module = rule.get("module") + base_class_name = rule.get("class_name") + + if base_module and base_class_name: + return {"module": base_module, "class_name": base_class_name} + + # No matching pattern found + return None + + def get_library_path(new_package_path: Path, config_path: Path, subfolder: bool = True) -> Path: """ Get the desired library path with the following format: diff --git a/src/pyconverter/xml2py/writer.py b/src/pyconverter/xml2py/writer.py index a2d612428..a030d8f24 100644 --- a/src/pyconverter/xml2py/writer.py +++ b/src/pyconverter/xml2py/writer.py @@ -34,6 +34,7 @@ import pyconverter.xml2py.utils.regex_pattern as pat from pyconverter.xml2py.utils.utils import ( create_name_map, + get_base_class_for_pattern, get_comment_command_dict, get_config_data_value, get_library_path, @@ -326,9 +327,13 @@ def add_additional_source_files( template_path: Path, package_structure: dict, config_path: Path, + library_path: Path, ) -> dict: """ - Add additional source files to the package structure if specified in the config. + Add additional source files to the package structure from the template. + + This function handles pre-existing Python files at any depth in the template + directory structure, allowing them to coexist with generated files. Parameters ---------- @@ -338,33 +343,102 @@ def add_additional_source_files( Dictionary representing the package structure. config_path: Path Path object of the configuration file. + library_path: Path + Path object of the library directory where files are generated. Returns ------- - dict | None - Updated package structure or None if no additional files were added. + dict + Updated package structure. """ - # Get all the python files in the template source directory - template_source_path = template_path / "src" - additional_files = list(template_source_path.glob("**/*.py")) + # Calculate the equivalent path in the template directory + # library_path format: package/src/pyconverter/generatedcommands/subfolder/subsubfolder/ + # We need to find the corresponding path in the template + + # Extract the library structure from the library_path + library_name_structured = get_config_data_value(config_path, "library_name_structured") + if not library_name_structured: + logging.info( + "No library structure defined in config. Skipping addition of template source files." + ) + return package_structure + + subfolder_values = get_config_data_value(config_path, "subfolders") + + # Build the template library path by reconstructing from template_path + template_library_path = template_path / "src" + for part in library_name_structured: + template_library_path = template_library_path / part + if subfolder_values: + for subfolder in subfolder_values: + template_library_path = template_library_path / subfolder + + if not template_library_path.exists(): + logging.info( + "Template library path does not exist. Skipping addition of template source files." + ) + return package_structure + + # Find all Python files in the template library path + additional_files = list(template_library_path.glob("**/*.py")) + if len(additional_files) > 0: for file_path in additional_files: - relative_path = file_path.relative_to(template_source_path) + # Skip __init__.py files as they're handled separately + if file_path.name == "__init__.py": + continue + + # Get relative path from the template library path + try: + relative_path = file_path.relative_to(template_library_path) + except ValueError: + continue + parts = relative_path.parts - if len(parts) < 2: - continue # Skip files that are not in a module folder - module_name = parts[-2] + + # Determine the module name (parent directory) + if len(parts) == 1: + # File is directly in the library root, skip it + continue + + # Get the module path (all parts except the filename) + module_parts = parts[:-1] + module_name = module_parts[0] if len(module_parts) == 1 else "/".join(module_parts) + + # For nested structures, use only the immediate parent as module name + immediate_module = parts[-2] + + # Get the class/file name class_file_name = parts[-1][:-3] # Remove .py extension class_name = class_file_name.title().replace("_", "") - if module_name not in package_structure: - package_structure[module_name] = {} - if class_file_name not in package_structure[module_name]: - package_structure[module_name][class_file_name] = [class_name, []] - # read the content of the additional file and update the class structure - with open(file_path, "r", encoding="utf-8") as fid: - content = fid.read() - method_names = re.findall(pat.DEF_METHOD, content) - package_structure[module_name][class_file_name][1].extend(method_names) + + # Initialize module in package structure if it doesn't exist + if immediate_module not in package_structure: + package_structure[immediate_module] = {} + + # Add file to package structure if not already there + if class_file_name not in package_structure[immediate_module]: + package_structure[immediate_module][class_file_name] = [class_name, []] + + # Read the content from template and copy to library_path + try: + # Construct the destination path using library_path + dest_module_path = library_path / immediate_module + dest_module_path.mkdir(parents=True, exist_ok=True) + dest_file_path = dest_module_path / f"{class_file_name}.py" + + # Copy file if it doesn't exist in destination + if not dest_file_path.exists(): + shutil.copy(file_path, dest_file_path) + + # Read the content and extract method names + with open(dest_file_path, "r", encoding="utf-8") as fid: + content = fid.read() + method_names = re.findall(pat.DEF_METHOD, content) + package_structure[immediate_module][class_file_name][1].extend(method_names) + except Exception as e: + logging.warning(f"Could not process {file_path}: {e}") + return package_structure @@ -488,8 +562,21 @@ def write_source( # Create the class file and structure if it doesn't exist yet if not file_path.is_file(): class_structure = [] + + # Check if this class should inherit from a base class + base_class_info = get_base_class_for_pattern(config_path, module_name, class_name) + with open(file_path, "w", encoding="utf-8") as fid: - fid.write(f"class {class_name}:\n") + # Write import statement if base class is configured + if base_class_info: + import_stmt = ( + f"from {base_class_info['module']} " + f"import {base_class_info['class_name']}\n\n" + ) + fid.write(import_stmt) + fid.write(f"class {class_name}({base_class_info['class_name']}):\n") + else: + fid.write(f"class {class_name}:\n") else: # Get the class structure class_structure = package_structure[module_name][file_name][1] @@ -530,7 +617,9 @@ def write_source( write__init__file(library_path) # Update package_structure if needed - package_structure = add_additional_source_files(template_path, package_structure, config_path) + package_structure = add_additional_source_files( + template_path, package_structure, config_path, library_path + ) if check_structure_map: for command_name in name_map.keys(): diff --git a/tests/conftest.py b/tests/conftest.py index 65b4014f4..828bd0069 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -100,6 +100,69 @@ def version_variables(load_terms): return load_terms[1] +@pytest.fixture +def base_class_test_config(tmp_path): + """ + Create a temporary config.yaml file for testing base class inheritance + without using the general config.yaml file. + + Returns + ------- + Path + Path to the temporary config file with base class rules. + """ + config_content = """ +project_name: PyConverter-TestConfig + +library_name_structured: + - pyconverter + - generatedcommands + +base_class: + rules: + # Specific class gets a special base class (checked first) + - pattern: "prep7/Meshing" + module: "ansys.mapdl.core._commands.prep" + class_name: "PrepBase" + + # Specific module inheritance + - pattern: "apdl/*" + module: "ansys.mapdl.core._commands.apdl" + class_name: "APDLBase" + + # Make all classes inherit from BaseCommandClass (fallback) + - pattern: "*" + module: "ansys.mapdl.core._commands" + class_name: "CommandsBase" +""" + config_file = tmp_path / "test_config.yaml" + config_file.write_text(config_content) + return config_file + + +@pytest.fixture +def base_class_empty_config(tmp_path): + """ + Create a temporary config.yaml file with no base class rules + for testing the default behavior. + + Returns + ------- + Path + Path to the temporary config file without base class rules. + """ + config_content = """ +project_name: PyConverter-TestConfig + +library_name_structured: + - pyconverter + - generatedcommands +""" + config_file = tmp_path / "test_config_no_base.yaml" + config_file.write_text(config_content) + return config_file + + @pytest.fixture def command_map(directory_path): return wrt.convert(directory_path)[0] diff --git a/tests/test_base_class_feature.py b/tests/test_base_class_feature.py new file mode 100644 index 000000000..6a30e9d34 --- /dev/null +++ b/tests/test_base_class_feature.py @@ -0,0 +1,81 @@ +# Copyright (C) 2023 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +""" +Test script to demonstrate the base class inheritance feature. + +This script tests the pattern-based base class configuration. +""" + +from pyconverter.xml2py.utils.utils import get_base_class_for_pattern + + +def test_pattern_matching_with_rules(base_class_test_config): + """Test various pattern matching scenarios with base class rules.""" + + # Test cases + test_cases = [ + ("apdl", "Abbreviations", "apdl/*", "APDLBase"), + ("prep7", "Meshing", "prep7/Meshing", "PrepBase"), + ("database", "Save", "*", "CommandsBase"), + ("post1", "Analysis", "*", "CommandsBase"), + ] + + for module_name, class_name, expected_pattern, expected_base_class in test_cases: + result = get_base_class_for_pattern(base_class_test_config, module_name, class_name) + + assert result is not None, f"Expected result for {module_name}/{class_name}" + assert ( + result["class_name"] == expected_base_class + ), f"Expected {expected_base_class} for {module_name}/{class_name}, got {result['class_name']}" # noqa: E501 + + +def test_pattern_matching_without_rules(base_class_empty_config): + """Test that no inheritance is applied when config has no base class rules.""" + + test_cases = [ + ("apdl", "Abbreviations"), + ("prep7", "Meshing"), + ("database", "Save"), + ("post1", "Analysis"), + ] + + for module_name, class_name in test_cases: + result = get_base_class_for_pattern(base_class_empty_config, module_name, class_name) + + assert ( + result is None + ), f"Expected None for {module_name}/{class_name} when no rules defined, got {result}" + + +def test_specific_pattern_takes_precedence(base_class_test_config): + """Test that more specific patterns are matched before wildcard patterns.""" + + # prep7/Meshing should match the specific pattern, not the wildcard + result = get_base_class_for_pattern(base_class_test_config, "prep7", "Meshing") + + assert result is not None + assert result["class_name"] == "PrepBase" + assert result["module"] == "ansys.mapdl.core._commands.prep" + + +def test_module_wildcard_pattern(base_class_test_config): + """Test that module-level wildcard patterns work correctly.""" + + # Any class in apdl module should get APDLBase + result = get_base_class_for_pattern(base_class_test_config, "apdl", "SomeClass") + + assert result is not None + assert result["class_name"] == "APDLBase" + assert result["module"] == "ansys.mapdl.core._commands.apdl" + + +def test_global_wildcard_pattern(base_class_test_config): + """Test that global wildcard pattern catches everything not matched.""" + + # Random module/class should match the global wildcard + result = get_base_class_for_pattern(base_class_test_config, "random", "RandomClass") + + assert result is not None + assert result["class_name"] == "CommandsBase" + assert result["module"] == "ansys.mapdl.core._commands"