Repository: dreadnode/rigging

Issue:
rigging.interact._animate could benefit from itertools.cycle
The current `_animate` function in `rigging.interact` manually tracks indexes for looping, as shown below:

```python3
async def _animate(*, delay: float = 0.5, chars: list[str] | None = None, color: str = Fore.BLUE) -> None:
    chars = chars or ["   ", ".  ", ".. ", "..."]
    i = 0
    while True:
        print(f"{color}{chars[i]}{Style.RESET_ALL}", end="\r")
        sys.stdout.flush()
        await asyncio.sleep(delay)
        i = (i + 1) % len(chars)
```

You could be lazy and use `itertools.cycle` for this instead, as shown below:

```python3
import itertools

async def _animate(*, delay: float = 0.5, chars: list[str] | None = None, color: str = Fore.BLUE) -> None:
    chars = itertools.cycle(chars or ["   ", ".  ", ".. ", "..."])
    while True:
        print(f"{color}{next(chars)}{Style.RESET_ALL}", end="\r")
        sys.stdout.flush()
        await asyncio.sleep(delay)
```

This would give you the added benefit of being able to use non-indexable items for `chars`, such as generators.

I can create a PR if you want but feel free to add it whenever w/o accreditation. I was just checking out the source and noticed this and wanted to pass it along.

Also, unless you have other reasons for using `sys.stdout.flush()` manually, `print` accepts `flush=True` which forcibly flushes `stdout`. The `flush` kwarg was added in 3.3.

Hints from issue comments:
@vabruzzo: Hi @PyWoody . Thanks for the feedback, great suggestion. If you would like accreditation, feel free to open a PR and we will review/merge. If not we can implement it. Thanks@monoxgas: Sorry for the long delay getting this merged, thanks for the contribution!

Solution patch:
diff --git a/pyproject.toml b/pyproject.toml
index a3ca580..136c3a2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "rigging"
-version = "2.2.6"
+version = "2.2.7"
 description = "LLM Interaction Framework"
 authors = ["Nick Landers <monoxgas@gmail.com>"]
 license = "MIT"
diff --git a/rigging/__init__.py b/rigging/__init__.py
index e0f8611..6930bd6 100644
--- a/rigging/__init__.py
+++ b/rigging/__init__.py
@@ -19,7 +19,7 @@ from rigging.tool import ApiTool, Tool
 from rigging.util import await_
 
 # TODO: Migrate to importlib for this
-__version__ = "2.2.6"
+__version__ = "2.2.7"
 
 __all__ = [
     "get_generator",
diff --git a/rigging/interact.py b/rigging/interact.py
index 34ff01f..53c90c9 100644
--- a/rigging/interact.py
+++ b/rigging/interact.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 import asyncio
-import sys
+import itertools
 import typing as t
 
 from colorama import Fore, Style
@@ -14,13 +14,10 @@ if t.TYPE_CHECKING:
 
 
 async def _animate(*, delay: float = 0.5, chars: list[str] | None = None, color: str = Fore.BLUE) -> None:
-    chars = chars or ["   ", ".  ", ".. ", "..."]
-    i = 0
+    chars = itertools.cycle(chars or ["   ", ".  ", ".. ", "..."])
     while True:
-        print(f"{color}{chars[i]}{Style.RESET_ALL}", end="\r")
-        sys.stdout.flush()
+        print(f"{color}{next(chars)}{Style.RESET_ALL}", end="\r", flush=True)
         await asyncio.sleep(delay)
-        i = (i + 1) % len(chars)
 
 
 async def interact(
diff --git a/rigging/model.py b/rigging/model.py
index a0b8405..0176bf7 100644
--- a/rigging/model.py
+++ b/rigging/model.py
@@ -8,6 +8,7 @@ import re
 import typing as t
 from xml.etree import ElementTree as ET
 
+import typing_extensions as te
 import xmltodict  # type: ignore
 from pydantic import (
     BeforeValidator,
@@ -207,7 +208,7 @@ class Model(BaseXmlModel):
     # about migrating from pydantic-xml
 
     @classmethod
-    def from_text(cls, content: str) -> list[tuple[ModelT, slice]]:
+    def from_text(cls, content: str) -> list[tuple[te.Self, slice]]:
         """
         The core parsing method which attempts to extract and parse as many
         valid instances of a model from semi-structured text.
@@ -224,7 +225,8 @@ class Model(BaseXmlModel):
         """
         cls.ensure_valid()
 
-        pattern = r"(<([\w-]+).*?>((.*?)</\2>))"
+        tag_name = cls.__xml_tag__
+        pattern = f"(<({tag_name}).*?>((.*?)</{tag_name}>))"
         matches = [m for m in re.finditer(pattern, content, flags=re.DOTALL) if m.group(2) == cls.__xml_tag__]
 
         if not matches:
@@ -236,7 +238,7 @@ class Model(BaseXmlModel):
 
         sorted_matches = sorted(matches, key=lambda m: len(m.group(4)), reverse=True)
 
-        extracted: list[tuple[ModelT, slice]] = []
+        extracted: list[tuple[te.Self, slice]] = []
         exceptions: list[Exception] = []
         for match in sorted_matches:
             full_text, _, inner_with_end_tag, inner = match.groups()
@@ -263,7 +265,7 @@ class Model(BaseXmlModel):
                     if cls.is_simple()
                     else cls.from_xml(escape_xml(full_text))
                 )
-                extracted.append((model, slice(match.start(), match.end())))  # type: ignore [arg-type]
+                extracted.append((model, slice(match.start(), match.end())))
             except Exception as e:
                 exceptions.append(e)
                 continue
@@ -274,10 +276,11 @@ class Model(BaseXmlModel):
         if not extracted:
             raise exceptions[0]
 
-        return extracted
+        # sort back to original order
+        return sorted(extracted, key=lambda x: x[1].start)
 
     @classmethod
-    def one_from_text(cls, content: str, *, fail_on_many: bool = False) -> tuple[ModelT, slice]:
+    def one_from_text(cls, content: str, *, fail_on_many: bool = False) -> tuple[te.Self, slice]:
         """
         Finds and returns a single match from the given text content.
 
@@ -291,7 +294,7 @@ class Model(BaseXmlModel):
         Raises:
             ValidationError: If multiple matches are found and fail_on_many is True.
         """
-        matches = cls.from_text(content)  # type: ignore [var-annotated]
+        matches = cls.from_text(content)
         if fail_on_many and len(matches) > 1:
             raise ValidationError("Multiple matches found with 'fail_on_many=True'")
         return max(matches, key=lambda x: x[1].stop - x[1].start)
@@ -423,15 +426,21 @@ class DelimitedAnswer(Model):
     content: str
     _delimiters: t.ClassVar[list[str]] = [",", "-", "/", "|"]
 
+    _items: list[str] = []
+
     @property
     def items(self) -> list[str]:
         """Parsed items from the content."""
+        if self._items:
+            return self._items
+
         split_sizes: dict[str, int] = {}
         for delimiter in self._delimiters:
             split_sizes[delimiter] = len(self.content.split(delimiter))
         delimiter = max(split_sizes, key=split_sizes.get)  # type: ignore [arg-type]
         split = [i.strip(" \"'\t\r\n") for i in self.content.split(delimiter)]
-        return [s for s in split if s]
+        self._items = [s for s in split if s]
+        return self._items
 
     @field_validator("content", mode="before")
     def parse_str_to_list(cls, v: t.Any) -> t.Any:
diff --git a/rigging/parsing.py b/rigging/parsing.py
index 1ba1c48..e6c22dd 100644
--- a/rigging/parsing.py
+++ b/rigging/parsing.py
@@ -128,4 +128,4 @@ def try_parse_many(text: str, *types: type[ModelT], fail_on_missing: bool = Fals
             if fail_on_missing:
                 raise e
 
-    return parsed
+    return sorted(parsed, key=lambda x: x[1].start)


Test patch:
diff --git a/tests/test_xml_parsing.py b/tests/test_xml_parsing.py
index f62f319..d62ba0e 100644
--- a/tests/test_xml_parsing.py
+++ b/tests/test_xml_parsing.py
@@ -14,6 +14,9 @@ from rigging.model import (
     attr,
     element,
 )
+from rigging.parsing import parse_many
+
+# Models to use during tests
 
 
 class NameWithThings(Model):
@@ -26,91 +29,161 @@ class Inner(Model):
     content: str
 
 
+class Outer(Model):
+    content: str
+
+
+class A(Model):
+    content: str
+
+
+class B(Model):
+    content: str
+
+
+class C(Model):
+    content: str
+
+
+class Tag(Model):
+    content: str
+
+
 class Wrapped(Model):
     inners: list[Inner] = element()
 
 
+# Tests
+
+
 @pytest.mark.parametrize(
-    "content, expectations",
+    "content, models",
     [
-        pytest.param("<answer>hello</answer>", [(Answer, "hello")], id="single_answer"),
-        pytest.param("Random data <question>hello</question>", [(Question, "hello")], id="single_question"),
+        pytest.param("<answer>hello</answer>", [Answer(content="hello")], id="single_answer"),
+        pytest.param("Random data <question>hello</question>", [Question(content="hello")], id="single_question"),
         pytest.param(
-            "<answer> <question>hello</question>", [(Question, "hello")], id="single_question_with_unrelated_tag"
+            "<answer> <question>hello</question>", [Question(content="hello")], id="single_question_with_unrelated_tag"
         ),
         pytest.param(
             "<answer>hello</answer><question>world</question>",
-            [(Answer, "hello"), (Question, "world")],
+            [Answer(content="hello"), Question(content="world")],
             id="answer_and_question",
         ),
         pytest.param(
             "<question>hello</question><answer>world</answer>",
-            [(Question, "hello"), (Answer, "world")],
+            [Question(content="hello"), Answer(content="world")],
             id="question_and_answer",
         ),
         pytest.param(
             "Sure I'll answer between <answer> tags. <answer>hello</answer>",
-            [(Answer, "hello")],
+            [Answer(content="hello")],
             id="answer_with_duplicate_start_tag",
         ),
         pytest.param(
             "Sure I'll answer between <answer></answer> tags. <answer>hello</answer>",
-            [(Answer, "hello")],
+            [Answer(content=""), Answer(content="hello")],
             id="answer_with_duplicate_tags",
         ),
         pytest.param(
             "<question> Should I answer between <answer> tags? </question> <answer>hello</answer>",
-            [(Question, " Should I answer between <answer> tags? "), (Answer, "hello")],
+            [Question(content=" Should I answer between <answer> tags? "), Answer(content="hello")],
             id="question_with_answer_tag",
         ),
         pytest.param(
             "<question> Should I answer between <answer> tags? </question> <answer>hello</answer>",
-            [(Question, " Should I answer between <answer> tags? "), (Answer, "hello")],
+            [Question(content=" Should I answer between <answer> tags? "), Answer(content="hello")],
             id="question_with_answer_tag",
         ),
         pytest.param(
             "<question-answer><question>hello</question><answer>world</answer></question-answer>",
-            [(QuestionAnswer, QuestionAnswer(question=Question(content="hello"), answer=Answer(content="world")))],
+            [QuestionAnswer(question=Question(content="hello"), answer=Answer(content="world"))],
             id="question_answer",
         ),
         pytest.param(
             "<delimited-answer>\n- hello\n - world</delimited-answer>",
-            [(DelimitedAnswer, ["hello", "world"])],
+            [DelimitedAnswer(content="\n- hello\n - world", _items=["hello", "world"])],
             id="newline_delimited_answer",
         ),
         pytest.param(
             "<delimited-answer>hello, world, foo | bar</delimited-answer>",
-            [(DelimitedAnswer, ["hello", "world", "foo | bar"])],
+            [DelimitedAnswer(content="hello, world, foo | bar", _items=["hello", "world", "foo | bar"])],
             id="comma_delimited_answer",
         ),
         pytest.param(
             "<delimited-answer>hello / world / foo / bar, test | value</delimited-answer>",
-            [(DelimitedAnswer, ["hello", "world", "foo", "bar, test | value"])],
+            [
+                DelimitedAnswer(
+                    content="hello / world / foo / bar, test | value",
+                    _items=["hello", "world", "foo", "bar, test | value"],
+                )
+            ],
             id="slash_delimited_answer",
         ),
         pytest.param(
             '<name-with-things name="test"><thing>a</thing><thing>b</thing></name-with-things>',
-            [(NameWithThings, NameWithThings(name="test", things=["a", "b"]))],
+            [NameWithThings(name="test", things=["a", "b"])],
             id="name_with_things",
         ),
         pytest.param(
             '<wrapped><inner type="cat">meow</inner><inner type="dog">bark</inner></wrapped>',
-            [(Wrapped, Wrapped(inners=[Inner(type="cat", content="meow"), Inner(type="dog", content="bark")]))],
+            [Wrapped(inners=[Inner(type="cat", content="meow"), Inner(type="dog", content="bark")])],
             id="wrapped",
         ),
+        pytest.param(
+            "<outer>text before <inner>nested content</inner> text after</outer>",
+            [Outer(content="text before <inner>nested content</inner> text after")],
+            id="outer_with_inner_tag",
+        ),
+        pytest.param(
+            "<outer>Some text with <inner> incomplete tags</outer>",
+            [Outer(content="Some text with <inner> incomplete tags")],
+            id="incomplete_inner_tag",
+        ),
+        pytest.param(
+            "Commentary about the `<inner>` tag\n\n<outer>\nsomething is <inner> and </inner>\n</outer>",
+            [Outer(content="\nsomething is <inner> and </inner>\n")],
+            id="comment_before_tag",
+        ),
+        pytest.param(
+            "<a>first <b>overlap <a>nested</a> continue</b> end</a>",
+            [A(content="nested")],
+            id="overlapping_tags",
+        ),
+        pytest.param(
+            "<outer>level 1 <middle>level 2 <inner>level 3</inner> still 2</middle> back to 1</outer>",
+            [Outer(content="level 1 <middle>level 2 <inner>level 3</inner> still 2</middle> back to 1")],
+            id="multiple_nested_levels",
+        ),
+        pytest.param(
+            "Here's how to use <tag>: <tag>actual content</tag>",
+            [Tag(content="actual content")],
+            id="tag_in_text_and_xml",
+        ),
+        pytest.param(
+            "<outer>Text with &lt;inner&gt; as escaped HTML entities</outer>",
+            [Outer(content="Text with <inner> as escaped HTML entities")],
+            id="escaped_xml_in_content",
+        ),
+        pytest.param(
+            "<answer>first</answer> text <answer>second</answer> more text <answer>third</answer>",
+            [Answer(content="first"), Answer(content="second"), Answer(content="third")],
+            id="multiple_same_tags_order",
+        ),
     ],
 )
-def test_xml_parsing(content: str, expectations: list[tuple[Model, str]]) -> None:
-    # TODO: Sort out the types here a break this into focused functions
-    for model, expected in expectations:
-        obj, _ = t.cast(tuple[Model, slice], model.one_from_text(content))
-        assert obj is not None, "Failed to parse model"
-        if isinstance(expected, str):
-            assert getattr(obj, "content", None) == expected, "Failed to parse content"
-        if isinstance(expected, list):
-            assert getattr(obj, "items", None) == expected, "Failed to parse items"
-        elif isinstance(expected, Model):
-            assert obj.model_dump() == expected.model_dump(), "Failed to parse model"
+def test_xml_parsing(content: str, models: list[Model]) -> None:
+    parsed = parse_many(content, *{type(m) for m in models})
+    print(models)
+    print(parsed)
+
+    assert len(parsed) == len(models), "Failed to parse set"
+    for (obj, _), expected in zip(parsed, models):
+        print(obj)
+        assert (
+            obj.model_dump() == expected.model_dump()
+        ), f"Failed to parse model {expected.__class__.__name__} <- {str(obj)} ({parsed})"
+
+    print("---")
 
 
 @pytest.mark.parametrize(
@@ -174,7 +247,7 @@ def test_xml_parsing(content: str, expectations: list[tuple[Model, str]]) -> Non
 )
 def test_xml_parsing_with_validation(content: str, model: Model, expectation: t.ContextManager[t.Any]) -> None:
     with expectation:
-        model.from_text(content)  # type: ignore [var-annotated]
+        model.from_text(content)
 
 
 @pytest.mark.parametrize(
@@ -189,5 +262,48 @@ def test_xml_parsing_with_validation(content: str, model: Model, expectation: t.
     ],
 )
 def test_xml_parsing_sets(content: str, count: int, model: Model) -> None:
-    models = model.from_text(content)  # type: ignore [var-annotated]
+    models = model.from_text(content)
     assert len(models) == count, "Failed to parse model set"
+
+
+def test_tag_order_preservation() -> None:
+    content = "<answer>first</answer> text <answer>second</answer> more text <answer>third</answer>"
+    answers = [model for model, _ in Answer.from_text(content)]
+    assert len(answers) == 3
+    assert answers[0].content == "first"
+    assert answers[1].content == "second"
+    assert answers[2].content == "third"
+
+
+def test_nested_tag_parsing() -> None:
+    content = "<outer>text before <a>nested content</a> text after</outer>"
+
+    # Test parsing the outer tag
+    outer, _ = Outer.one_from_text(content)
+    assert outer.content == "text before <a>nested content</a> text after"
+
+    # Test parsing the inner tag
+    inner, _ = A.one_from_text(content)
+    assert inner.content == "nested content"
+
+
+def test_same_tag_mentioned_in_text() -> None:
+    """Test that mentions of tags in text don't confuse the parser."""
+    content = "Commentary about the `<outer>` tag\n\n<outer>\nsomething is <inner> and </inner>\n</outer>"
+
+    outer, _ = Outer.one_from_text(content)
+    assert outer.content == "\nsomething is <inner> and </inner>\n"
+
+
+def test_nested_incomplete_tags() -> None:
+    """Test handling of content with incomplete nested tags."""
+    content = "<outer>Content with <inner> incomplete tag</outer>"
+
+    outer, _ = Outer.one_from_text(content)
+    assert outer.content == "Content with <inner> incomplete tag"
+
+
+def test_edge_case_xml_entities() -> None:
+    content = "<outer>Content with &lt;tag&gt; as XML entities</outer>"
+    outer, _ = Outer.one_from_text(content)
+    assert outer.content == "Content with <tag> as XML entities"