Repository: ansys/pydyna

Issue:
Deck.expand() does not handle multiline include file name
### 🔍 Before submitting the issue

- [x] I have searched among the existing issues
- [x] I am using a Python virtual environment

### 🐞 Description of the bug

expand() method fails to handle *INCLUDE with filename in multiple lines using '+'.

### 📝 Steps to reproduce

-

### 💻 Which operating system are you using?

Windows

### 📀 Which ANSYS version are you using?

N/A

### 🐍 Which Python version are you using?

3.12

### 📦 Installed packages

```shell
ansys-api-dyna==0.4.2
ansys-api-platform-instancemanagement==1.1.2
ansys-dpf-core==0.13.4
ansys-dyna-core==0.7.0
ansys-platform-instancemanagement==1.1.2
ansys-tools-path==0.7.1
anyio==4.8.0
appdirs==1.4.4
argon2-cffi==23.1.0
argon2-cffi-bindings==21.2.0
arrow==1.3.0
asttokens==3.0.0
async-lru==2.0.4
attrs==25.1.0
babel==2.16.0
beautifulsoup4==4.12.3
bleach==6.2.0
cachetools==5.5.1
certifi==2024.12.14
cffi==1.17.1
charset-normalizer==3.4.1
click==8.1.8
colorama==0.4.6
comm==0.2.2
contourpy==1.3.1
cycler==0.12.1
debugpy==1.8.12
decorator==5.1.1
defusedxml==0.7.1
executing==2.2.0
fastjsonschema==2.21.1
fonttools==4.55.7
fqdn==1.5.1
google-api-core==2.24.1
google-api-python-client==2.160.0
google-auth==2.38.0
google-auth-httplib2==0.2.0
googleapis-common-protos==1.66.0
grpcio==1.70.0
h11==0.14.0
hollerith==0.6.0
httpcore==1.0.7
httplib2==0.22.0
httpx==0.28.1
idna==3.10
importlib_metadata==8.6.1
ipykernel==6.29.5
ipython==8.31.0
ipywidgets==8.1.5
isoduration==20.11.0
jedi==0.19.2
Jinja2==3.1.5
json5==0.10.0
jsonpointer==3.0.0
jsonschema==4.23.0
jsonschema-specifications==2024.10.1
jupyter==1.1.1
jupyter-console==6.6.3
jupyter-events==0.11.0
jupyter-lsp==2.2.5
jupyter_client==8.6.3
jupyter_core==5.7.2
jupyter_server==2.15.0
jupyter_server_terminals==0.5.3
jupyterlab==4.3.4
jupyterlab_pygments==0.3.0
jupyterlab_server==2.27.3
jupyterlab_widgets==3.0.13
kiwisolver==1.4.8
MarkupSafe==3.0.2
matplotlib==3.10.0
matplotlib-inline==0.1.7
mistune==3.1.1
nbclient==0.10.2
nbconvert==7.16.6
nbformat==5.10.4
nest-asyncio==1.6.0
notebook==7.3.2
notebook_shim==0.2.4
numpy==2.2.2
overrides==7.7.0
packaging==24.2
pandas==2.2.3
pandocfilters==1.5.1
parso==0.8.4
pillow==11.1.0
platformdirs==4.3.6
pooch==1.8.2
prometheus_client==0.21.1
prompt_toolkit==3.0.50
proto-plus==1.26.0
protobuf==5.29.3
psutil==6.1.1
pure_eval==0.2.3
pyasn1==0.6.1
pyasn1_modules==0.4.1
pycparser==2.22
Pygments==2.19.1
pyparsing==3.2.1
python-dateutil==2.9.0.post0
python-json-logger==3.2.1
pytz==2024.2
pyvista==0.44.2
pywin32==308
pywinpty==2.0.14
PyYAML==6.0.2
pyzmq==26.2.0
referencing==0.36.2
requests==2.32.3
rfc3339-validator==0.1.4
rfc3986-validator==0.1.1
rpds-py==0.22.3
rsa==4.9
scooby==0.10.0
Send2Trash==1.8.3
setuptools==75.8.0
six==1.17.0
sniffio==1.3.1
soupsieve==2.6
stack-data==0.6.3
terminado==0.18.1
tinycss2==1.4.0
tornado==6.4.2
tqdm==4.67.1
traitlets==5.14.3
types-python-dateutil==2.9.0.20241206
typing_extensions==4.12.2
tzdata==2025.1
uri-template==1.3.0
uritemplate==4.1.1
urllib3==2.3.0
vtk==9.3.1
wcwidth==0.2.13
webcolors==24.11.1
webencodings==0.5.1
websocket-client==1.8.0
wheel==0.45.1
widgetsnbextension==4.0.13
zipp==3.21.0
```

Hints from issue comments:
@RaphaelHeiniger: Hi @MiteshLalwala could you upload an example LS-DYNA input file and an example code that shows the problem? Thanks!@MiteshLalwala: @RaphaelHeiniger - Here is an example code snippet.
The include file can be any LS-Dyna file. The issue lies in the filename specified over two lines in the Input_Multiline_line_include file. This method is valid for specifying long filenames in LS-Dyna.
########################################################
```
from ansys.dyna.core import Deck, keywords as kwd

# It works for the single line include file
deck = Deck()
deck.append(kwd.Include(filename="Input_Single_line_include.k"))
deck = deck.expand()
print(deck) # Include file is expanded

# It does not work for the multi line include file
deck2 = Deck()
deck2.append(kwd.Include(filename="Input_Multiline_line_include.k"))
deck2 = deck2.expand()
print(deck2) # Include file is not expanded.
```

#############################################################
**Input_Single_line_include.k --->**

*KEYWORD
*INCLUDE
include_file_with_a_long_long_long_long_long_long_long_long_name.k
*END

#############################################################
**Input_Multiline_line_include.k --->**

*KEYWORD
*INCLUDE
include_file_with_a_long_long_long_ + 
long_long_long_long_long_name.k
*END@RaphaelHeiniger: @MiteshLalwala ok thanks, that clarifies.

Solution patch:
diff --git a/codegen/generate.py b/codegen/generate.py
index b02abe4c..b160031a 100644
--- a/codegen/generate.py
+++ b/codegen/generate.py
@@ -35,6 +35,7 @@ import typing
 from jinja2 import Environment, FileSystemLoader
 
 from keyword_generation.handlers.shared_field import SharedFieldHandler
+from keyword_generation.handlers.external_card import ExternalCardHandler
 from keyword_generation.handlers.handler_base import KeywordHandler
 
 
@@ -343,6 +344,7 @@ HANDLERS = collections.OrderedDict(
         "rename-property": handle_rename_property,
         "skip-card": handle_skipped_cards,
         "duplicate-card-group": handle_duplicate_card_group,
+        "external-card-implementation": ExternalCardHandler(),
         "shared-field": SharedFieldHandler(),
         "override-subkeyword": handle_override_subkeyword,
     }
diff --git a/codegen/keyword_generation/handlers/external_card.py b/codegen/keyword_generation/handlers/external_card.py
new file mode 100644
index 00000000..4d9be9f2
--- /dev/null
+++ b/codegen/keyword_generation/handlers/external_card.py
@@ -0,0 +1,37 @@
+import typing
+
+import keyword_generation.handlers.handler_base
+
+"""
+SCHEMA example:
+"index": 0,
+"card": {
+    "source": "include_card",
+    "card-name": "IncludeCard"
+},
+"mixin": "IncludeCardMixin"
+"""
+class ExternalCardHandler(keyword_generation.handlers.handler_base.KeywordHandler):
+
+    def handle(self, kwd_data: typing.Dict[str, typing.Any], settings: typing.Dict[str, typing.Any]) -> None:
+        """Transform `kwd_data` based on `settings`."""
+        kwd_data["mixins"] = []
+        kwd_data["mixin_imports"] = []
+        for setting in settings:
+            card_name = setting["card"]["card-name"]
+            card_index = setting["index"]
+            card_source = setting["card"]["source"]
+            mixin_name = setting["mixin"]
+            kwd_data["mixins"].append(mixin_name)
+            kwd_data["mixin_imports"].append({
+                "source": card_source,
+                "names": [card_name, mixin_name]
+            })
+            external_card = kwd_data["cards"][card_index]
+            external_card["external"] = {
+                "name": card_name
+            }
+
+    def post_process(self, kwd_data: typing.Dict[str, typing.Any]) -> None:
+        """Run after all handlers have run."""
+        pass
diff --git a/codegen/manifest.json b/codegen/manifest.json
index 987f5f88..0e57dde6 100644
--- a/codegen/manifest.json
+++ b/codegen/manifest.json
@@ -206,6 +206,20 @@
             ]
         }
     },
+    "INCLUDE_INCLUDE": {
+        "generation-options": {
+            "external-card-implementation": [
+                {
+                    "index": 0,
+                    "card": {
+                        "source": "include_card",
+                        "card-name": "IncludeCard"
+                    },
+                    "mixin": "IncludeCardMixin"
+                }
+            ]
+        }
+    },
     "PART_PART": {
         "generation-options": {
             "replace-card": [
diff --git a/codegen/templates/keyword.j2 b/codegen/templates/keyword.j2
index 92112071..7ae425e7 100644
--- a/codegen/templates/keyword.j2
+++ b/codegen/templates/keyword.j2
@@ -1,6 +1,12 @@
 {{license}}
 import typing
 {# TODO - only do this if there's a keyword without a default #}
+{% if keyword_data.mixin_imports %}
+{% for mixin_import in keyword_data.mixin_imports %}
+from ansys.dyna.core.lib.cards_.special.{{mixin_import.source}} import{% for name in mixin_import.names %}{{ ", " if not loop.first else " " }}{{name}}{% endfor %}
+{% endfor %}
+
+{% endif %}
 from ansys.dyna.core.lib.card import Card, Field, Flag
 from ansys.dyna.core.lib.config import use_lspp_defaults
 {% if keyword_data.duplicate %}
@@ -71,7 +77,18 @@ class {{card_set.name}}(Cards):
 
 {% endfor %}{# card_set in keyword_data.card_sets.sets #}
 {% endif %}{# keyword_data.card_sets #}
+{% if keyword_data.mixins %}
+class {{keyword_data.classname}}(
+    KeywordBase
+    {% if keyword_data.mixins %}
+{% for mixin in keyword_data.mixins %}
+    , {{mixin}}
+{% endfor %}
+    {% endif %}
+    ):
+{% else %}
 class {{keyword_data.classname}}(KeywordBase):
+{% endif %}
     """DYNA {{keyword_data.title}} keyword"""
 
     keyword = "{{keyword_data.keyword}}"
diff --git a/codegen/templates/keyword/card.j2 b/codegen/templates/keyword/card.j2
index 2a5ac467..7bb2d8e2 100644
--- a/codegen/templates/keyword/card.j2
+++ b/codegen/templates/keyword/card.j2
@@ -72,6 +72,8 @@
                 None,
 {% endif %}
                 data = kwargs.get("{{card.overall_name}}")),
+{% elif card.external %}
+            {{card.external.name}}(**kwargs)
 {% else %}
             Card(
                 [
diff --git a/codegen/templates/keyword/card_properties.j2 b/codegen/templates/keyword/card_properties.j2
index 392811cc..a10e67b3 100644
--- a/codegen/templates/keyword/card_properties.j2
+++ b/codegen/templates/keyword/card_properties.j2
@@ -34,6 +34,7 @@
         '''sets {{card.overall_name}} from the dataframe df'''
         self._cards[{{card.index}}].table = df
 
+{% elif card.external %}
 {% else %}
 {% for field in card.fields %}
 {% if field.used %}
diff --git a/doc/changelog/699.documentation.md b/doc/changelog/699.documentation.md
new file mode 100644
index 00000000..36c8cd34
--- /dev/null
+++ b/doc/changelog/699.documentation.md
@@ -0,0 +1 @@
+fix: Support multiline include
\ No newline at end of file
diff --git a/src/ansys/dyna/core/keywords/keyword_classes/auto/include.py b/src/ansys/dyna/core/keywords/keyword_classes/auto/include.py
index ba58b77d..c22fdcef 100644
--- a/src/ansys/dyna/core/keywords/keyword_classes/auto/include.py
+++ b/src/ansys/dyna/core/keywords/keyword_classes/auto/include.py
@@ -21,11 +21,15 @@
 # SOFTWARE.
 
 import typing
+from ansys.dyna.core.lib.cards_.special.include_card import IncludeCard, IncludeCardMixin
 from ansys.dyna.core.lib.card import Card, Field, Flag
 from ansys.dyna.core.lib.config import use_lspp_defaults
 from ansys.dyna.core.lib.keyword_base import KeywordBase
 
-class Include(KeywordBase):
+class Include(
+    KeywordBase
+    , IncludeCardMixin
+    ):
     """DYNA INCLUDE keyword"""
 
     keyword = "INCLUDE"
@@ -34,27 +38,6 @@ class Include(KeywordBase):
     def __init__(self, **kwargs):
         super().__init__(**kwargs)
         self._cards = [
-            Card(
-                [
-                    Field(
-                        "filename",
-                        str,
-                        0,
-                        80,
-                        kwargs.get("filename")
-                    ),
-                ],
-            ),
+            IncludeCard(**kwargs)
         ]
 
-    @property
-    def filename(self) -> typing.Optional[str]:
-        """Get or set the File name of file to be included in this keyword file.
-        Maximum 80 charcters. If the STAMPED_PART option is active, this is the DYNAIN file containing the results from metal stamping.
-        """ # nopep8
-        return self._cards[0].get_value("filename")
-
-    @filename.setter
-    def filename(self, value: str) -> None:
-        self._cards[0].set_value("filename", value)
-
diff --git a/src/ansys/dyna/core/lib/cards_/__init__.py b/src/ansys/dyna/core/lib/cards_/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/ansys/dyna/core/lib/cards_/special/__init__.py b/src/ansys/dyna/core/lib/cards_/special/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/ansys/dyna/core/lib/cards_/special/include_card.py b/src/ansys/dyna/core/lib/cards_/special/include_card.py
new file mode 100644
index 00000000..36fb144b
--- /dev/null
+++ b/src/ansys/dyna/core/lib/cards_/special/include_card.py
@@ -0,0 +1,110 @@
+# Copyright (C) 2021 - 2024 ANSYS, Inc. and/or its affiliates.
+# SPDX-License-Identifier: MIT
+#
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import typing
+
+from ansys.dyna.core.lib.card import Card, Field
+from ansys.dyna.core.lib.field_writer import write_comment_line
+from ansys.dyna.core.lib.format_type import format_type
+from ansys.dyna.core.lib.io_utils import write_or_return
+from ansys.dyna.core.lib.kwd_line_formatter import read_line
+
+## TODO codegen - add a codegen option to use a custom card
+#                 provide the class name, filename and mixin name
+
+
+class IncludeCard(Card):
+    def __init__(self, **kwargs):
+        super().__init__(
+            [
+                Field("filename", str, 0, 80, kwargs.get("filename")),
+            ],
+        )
+
+    def _read_line(self, buf: typing.TextIO) -> str:
+        line, to_exit = read_line(buf)
+        if to_exit:
+            assert False, "*INCLUDE card missing filename"
+        return line
+
+    def read(self, buf: typing.TextIO, parameter_set) -> None:
+        """Reads the card data from an input text buffer."""
+        line = self._read_line(buf)
+        filename = line
+        if filename.endswith(" +"):
+            filename = filename[:-2]
+            line = self._read_line(buf)
+            filename += line
+            if filename.endswith(" +"):
+                filename = filename[:-2]
+                line = self._read_line(buf)
+                filename += line
+        else:
+            filename = filename.strip()
+        self.set_value("filename", filename)
+        return False
+
+    def write(
+        self,
+        format: typing.Optional[format_type] = None,
+        buf: typing.Optional[typing.TextIO] = None,
+        comment: typing.Optional[bool] = True,
+    ) -> typing.Union[str, None]:
+        if format == None:
+            format = self._format_type
+
+        def _write(buf: typing.TextIO):
+            if self._is_active():
+                if comment:
+                    write_comment_line(buf, self._fields, format)
+                    buf.write("\n")
+                filename = self.get_value("filename")
+                if len(filename) > 236:
+                    raise Exception("Maximum filename length is 236 characters")
+                if len(filename) <= 80:
+                    right_justified_filename = f"{{0:<80}}".format(filename)
+                    buf.write(right_justified_filename)
+                else:
+                    buf.write(filename[0:78])
+                    buf.write(" +\n")
+                    if len(filename) <= 158:
+                        buf.write(filename[78:])
+                    else:
+                        buf.write(filename[78:156])
+                        buf.write(" +\n")
+                        buf.write(filename[156:])
+
+        return write_or_return(buf, _write)
+
+
+class IncludeCardMixin:
+    @property
+    def filename(self) -> typing.Optional[str]:
+        """Get or set the File name of file to be included in this keyword file.
+        Maximum 80 characters. If the STAMPED_PART option is active, this is the
+        DYNAIN file containing the results from metal stamping.
+        """  # nopep8
+        return self._cards[0].get_value("filename")
+
+    @filename.setter
+    def filename(self, value: str) -> None:
+        self._cards[0].set_value("filename", value)
diff --git a/src/ansys/dyna/core/lib/keyword_base.py b/src/ansys/dyna/core/lib/keyword_base.py
index 4b45a6ee..16c09d9c 100644
--- a/src/ansys/dyna/core/lib/keyword_base.py
+++ b/src/ansys/dyna/core/lib/keyword_base.py
@@ -256,10 +256,14 @@ class KeywordBase(Cards):
         # maybe after the keyword but before any $#
         self._read_data(buf, parameters)
 
-    def loads(self, value: str, parameters: ParameterSet = None) -> None:
-        """Load the keyword from string."""
+    def loads(self, value: str, parameters: ParameterSet = None) -> typing.Any:
+        """Load the keyword from string.
+
+        Return `self` to support chaining
+        """
         # TODO - add a method to load from a buffer.
         s = io.StringIO()
         s.write(value)
         s.seek(0)
         self.read(s, parameters)
+        return self


Test patch:
diff --git a/tests/test_keywords.py b/tests/test_keywords.py
index 70550d34..cc860c0d 100644
--- a/tests/test_keywords.py
+++ b/tests/test_keywords.py
@@ -901,6 +901,39 @@ def test_em_randles_batmac_rdltype(ref_string):
     assert s.write() == ref_string.test_em_randles_batmac_rdltype_2_3
 
 
+@pytest.mark.keywords
+def test_multiline_include_keyword(ref_string):
+    filename = "a"*60 + ".k"
+    i = kwd.Include(filename=filename)
+    assert i.write() == ref_string.test_one_line_include1
+    assert kwd.Include().loads(ref_string.test_one_line_include1).filename == filename
+    filename = "a"*78 + ".k"
+    i.filename = filename
+    assert i.write() == ref_string.test_one_line_include2
+    assert kwd.Include().loads(ref_string.test_one_line_include2).filename == filename
+    filename = "a"*80 + ".k"
+    i.filename = filename
+    assert i.write() == ref_string.test_two_line_include1
+    assert kwd.Include().loads(ref_string.test_two_line_include1).filename == filename
+    filename = "a"*156 + ".k"
+    i.filename = filename
+    assert i.write() == ref_string.test_two_line_include2
+    assert kwd.Include().loads(ref_string.test_two_line_include2).filename == filename
+    filename = "a"*160 + ".k"
+    i.filename = filename
+    assert i.write() == ref_string.test_three_line_include1
+    assert kwd.Include().loads(ref_string.test_three_line_include1).filename == filename
+    filename = "a"*234 + ".k"
+    i.filename = filename
+    assert i.write() == ref_string.test_three_line_include2
+    assert kwd.Include().loads(ref_string.test_three_line_include2).filename == filename
+    with pytest.raises(Exception):
+        i.filename = "a"*300 + ".k"
+        i.write()
+
+
+
+
 @pytest.mark.keywords
 def test_em_randles_solid_rdltype(ref_string):
     s = kwd.EmRandlesSolid(rdltype=1)
diff --git a/tests/testfiles/keywords/reference_string.py b/tests/testfiles/keywords/reference_string.py
index ce3d6223..48692720 100644
--- a/tests/testfiles/keywords/reference_string.py
+++ b/tests/testfiles/keywords/reference_string.py
@@ -100,6 +100,35 @@ $#     pid     ctype        np
 $#       x         y         z
                               """]
 
+test_one_line_include1 = """*INCLUDE
+$#                                                                      filename
+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.k                  """
+
+test_one_line_include2 = """*INCLUDE
+$#                                                                      filename
+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.k"""
+
+test_two_line_include1 = """*INCLUDE
+$#                                                                      filename
+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +
+aa.k"""
+
+test_two_line_include2 = """*INCLUDE
+$#                                                                      filename
+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +
+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.k"""
+
+test_three_line_include1 = """*INCLUDE
+$#                                                                      filename
+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +
+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +
+aaaa.k"""
+
+test_three_line_include2 = """*INCLUDE
+$#                                                                      filename
+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +
+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +
+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.k"""
 
 test_title_string = """*Keyword
 *DEFINE_CURVE_TITLE