Repository: idaholab/MontePy

Issue:
Material Specifications: cannot unpack non-iterable ValueNode object
@MicahGale How are material cards supposed to be formatted for MontePy to parse them? I keep getting the error in the subject line. When I inspect the `isotope_fractions` variable in `\data_inputs\material.py`, it is a `ListNode` object containing `ValueNode` objects. For any `ValueNode` object, I see no attribute that corresponds to the isotopic fraction (at least not one intuitively named). Additionally this object is non-iterable so the usage isn't correct either. Am I missing something?


Discussion:

Patch:
diff --git a/montepy/data_inputs/isotope.py b/montepy/data_inputs/isotope.py
index 5a5272a5..4303198d 100644
--- a/montepy/data_inputs/isotope.py
+++ b/montepy/data_inputs/isotope.py
@@ -20,20 +20,22 @@ class Isotope:
 
     def __init__(self, ZAID="", node=None):
         if node is not None and isinstance(node, ValueNode):
+            if node.type == float:
+                node = ValueNode(node.token, str, node.padding)
             self._tree = node
             ZAID = node.value
-        if "." in ZAID:
-            parts = ZAID.split(".")
-            try:
-                assert len(parts) == 2
-                int(parts[0])
-            except (AssertionError, ValueError) as e:
-                raise ValueError(f"ZAID: {ZAID} could not be parsed as a valid isotope")
-            self._ZAID = parts[0]
-            self.__parse_zaid()
+        parts = ZAID.split(".")
+        try:
+            assert len(parts) <= 2
+            int(parts[0])
+        except (AssertionError, ValueError) as e:
+            raise ValueError(f"ZAID: {ZAID} could not be parsed as a valid isotope")
+        self._ZAID = parts[0]
+        self.__parse_zaid()
+        if len(parts) == 2:
             self._library = parts[1]
         else:
-            raise ValueError(f"ZAID: {ZAID} could not be parsed as a valid isotope")
+            self._library = ""
 
     def __parse_zaid(self):
         """
diff --git a/montepy/data_inputs/material.py b/montepy/data_inputs/material.py
index 44516538..75cd3413 100644
--- a/montepy/data_inputs/material.py
+++ b/montepy/data_inputs/material.py
@@ -3,6 +3,8 @@ import copy
 from montepy.data_inputs import data_input, thermal_scattering
 from montepy.data_inputs.isotope import Isotope
 from montepy.data_inputs.material_component import MaterialComponent
+from montepy.input_parser import syntax_node
+from montepy.input_parser.material_parser import MaterialParser
 from montepy import mcnp_object
 from montepy.numbered_mcnp_object import Numbered_MCNP_Object
 from montepy.errors import *
@@ -26,6 +28,8 @@ class Material(data_input.DataInputAbstract, Numbered_MCNP_Object):
     :type input: Input
     """
 
+    _parser = MaterialParser()
+
     def __init__(self, input=None):
         self._material_components = {}
         self._thermal_scattering = None
@@ -37,7 +41,24 @@ class Material(data_input.DataInputAbstract, Numbered_MCNP_Object):
             self._number = num
             set_atom_frac = False
             isotope_fractions = self._tree["data"]
-            for isotope_node, fraction in isotope_fractions:
+            if isinstance(isotope_fractions, syntax_node.ListNode):
+                # in python 3.12 this can be replaced with itertools.batched
+                def batch_gen():
+                    it = iter(isotope_fractions)
+                    while batch := tuple(itertools.islice(it, 2)):
+                        yield batch
+
+                iterator = batch_gen()
+            elif isinstance(isotope_fractions, syntax_node.IsotopesNode):
+                iterator = iter(isotope_fractions)
+            else:  # pragma: no cover
+                # this is a fall through error, that should never be raised,
+                # but is here just in case
+                raise MalformedInputError(
+                    input,
+                    f"Material definitions for material: {self.number} is not valid.",
+                )
+            for isotope_node, fraction in iterator:
                 isotope = Isotope(node=isotope_node)
                 fraction.is_negatable_float = True
                 if not set_atom_frac:
diff --git a/montepy/input_parser/__init__.py b/montepy/input_parser/__init__.py
index 487d3fcb..a80864bb 100644
--- a/montepy/input_parser/__init__.py
+++ b/montepy/input_parser/__init__.py
@@ -4,6 +4,7 @@ from . import block_type
 from . import cell_parser
 from . import data_parser
 from . import input_reader
+from . import material_parser
 from . import mcnp_input
 from . import parser_base
 from . import read_parser
diff --git a/montepy/input_parser/material_parser.py b/montepy/input_parser/material_parser.py
new file mode 100644
index 00000000..f53e1b4b
--- /dev/null
+++ b/montepy/input_parser/material_parser.py
@@ -0,0 +1,31 @@
+# Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved.
+from montepy.input_parser.data_parser import DataParser
+from montepy.input_parser import syntax_node
+
+
+class MaterialParser(DataParser):
+    debugfile = None
+
+    @_(
+        "introduction isotopes",
+        "introduction isotopes parameters",
+    )
+    def material(self, p):
+        ret = {}
+        for key, node in p.introduction.nodes.items():
+            ret[key] = node
+        ret["data"] = p.isotopes
+        if hasattr(p, "parameters"):
+            ret["parameters"] = p.parameters
+        return syntax_node.SyntaxNode("data", ret)
+
+    @_("isotope_fractions", "number_sequence", "isotope_hybrid_fractions")
+    def isotopes(self, p):
+        return p[0]
+
+    @_("number_sequence isotope_fraction", "isotope_hybrid_fractions isotope_fraction")
+    def isotope_hybrid_fractions(self, p):
+        ret = p[0]
+        for node in p.isotope_fraction[1:]:
+            ret.append(node)
+        return ret


Test patch:
diff --git a/tests/test_material.py b/tests/test_material.py
index 56adfed9..aec22336 100644
--- a/tests/test_material.py
+++ b/tests/test_material.py
@@ -32,6 +32,22 @@ class testMaterialClass(TestCase):
         for component in material.material_components:
             self.assertEqual(material.material_components[component].fraction, 0.5)
 
+        # test implicit library with syntax tree errors
+        in_str = """m1 1001 0.33
+    8016 0.666667"""
+        input_card = Input(in_str.split("\n"), BlockType.DATA)
+        material = Material(input_card)
+        # test implicit library
+        in_str = "M20 1001 0.5 2001 0.5 8016.710nc 0.5"
+        input_card = Input([in_str], BlockType.DATA)
+        material = Material(input_card)
+        self.assertEqual(material.number, 20)
+        self.assertEqual(material.old_number, 20)
+        self.assertTrue(material.is_atom_fraction)
+        for component in material.material_components:
+            self.assertEqual(material.material_components[component].fraction, 0.5)
+
+        # test weight fraction
         in_str = "M20 1001.80c -0.5 8016.80c -0.5"
         input_card = Input([in_str], BlockType.DATA)
         material = Material(input_card)
@@ -164,8 +180,6 @@ class TestIsotope(TestCase):
             Isotope("1001.80c.5")
         with self.assertRaises(ValueError):
             Isotope("hi.80c")
-        with self.assertRaises(ValueError):
-            Isotope("1001")
 
     def test_isotope_metastable_init(self):
         isotope = Isotope("13426.02c")