Repository: guardrails-ai/guardrails (language: python)

Issue:
Add ChatGPT / arbitrary LLM callers
Partially addresses #18 by allowing devs to pass in any callable.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 10420a276..24d09c7c8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -42,6 +42,33 @@ jobs:
                 run: |
                     make lint
 
+    Typing:
+        runs-on: ubuntu-latest
+        strategy:
+            matrix:
+                python-version: ['3.8', '3.9', '3.10', '3.11']
+
+        steps:
+            -   uses: actions/checkout@v2
+            -   name: Set up Python ${{ matrix.python-version }}
+                uses: actions/setup-python@v2
+                with:
+                    python-version: ${{ matrix.python-version }}
+
+            -   uses: actions/cache@v2
+                with:
+                    path: ~/.cache/pip
+                    key: ${{ runner.os }}-pip
+
+            -   name: Install Dependencies
+                run: |
+                    python -m pip install --upgrade pip
+                    make dev
+
+            -   name: Static analysis with pyright
+                run: |
+                    make type
+
     Pytests:
         runs-on: ubuntu-latest
         strategy:
diff --git a/.gitignore b/.gitignore
index 4996b0624..7ba1488e7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,4 @@ scratch/
 test.db
 test.index
 htmlcov
+.python-version
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 9a4dffc5b..88bd60f72 100644
--- a/Makefile
+++ b/Makefile
@@ -5,6 +5,9 @@ autoformat:
 	isort --atomic guardrails/ tests/
 	docformatter --in-place --recursive guardrails tests
 
+type:
+	pyright guardrails/
+
 lint:
 	isort -c guardrails/ tests/
 	black guardrails/ tests/ --check
@@ -36,4 +39,9 @@ dev:
 full:
 	pip install -e ".[all]"
 
-all: autoformat lint docs test
\ No newline at end of file
+all: autoformat type lint docs test
+
+precommit:
+	# pytest -x -q --no-summary
+	pyright guardrails/
+	make lint
\ No newline at end of file
diff --git a/codecov.yml b/codecov.yml
index 2fc3d8749..2bde272a7 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -1,2 +1,3 @@
 ignore:
-  - "guardrails/version.py"
\ No newline at end of file
+  - "guardrails/version.py"
+  - "tests"
\ No newline at end of file
diff --git a/docs/javascripts/pipedrive.js b/docs/javascripts/pipedrive.js
new file mode 100644
index 000000000..6a1d5a545
--- /dev/null
+++ b/docs/javascripts/pipedrive.js
@@ -0,0 +1,1 @@
+(function(ss,ex){ window.ldfdr=window.ldfdr||function(){(ldfdr._q=ldfdr._q||[]).push([].slice.call(arguments));}; (function(d,s){ fs=d.getElementsByTagName(s)[0]; function ce(src){ var cs=d.createElement(s); cs.src=src; cs.async=1; fs.parentNode.insertBefore(cs,fs); }; ce('https://sc.lfeeder.com/lftracker_v1_'+ss+(ex?'_'+ex:'')+'.js'); })(document,'script'); })('3P1w24dwbwk7mY5n');
\ No newline at end of file
diff --git a/docs/javascripts/posthog.js b/docs/javascripts/posthog.js
new file mode 100644
index 000000000..fd3bbdacb
--- /dev/null
+++ b/docs/javascripts/posthog.js
@@ -0,0 +1,2 @@
+!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
+posthog.init('phc_LfFHHkxsQ7lXnVsGhtsNOgsBdmH5IsZN973bNSxK2bm',{api_host:'https://app.posthog.com'})
\ No newline at end of file
diff --git a/guardrails/applications/text2sql.py b/guardrails/applications/text2sql.py
index eccc67fa6..bafca2cf8 100644
--- a/guardrails/applications/text2sql.py
+++ b/guardrails/applications/text2sql.py
@@ -1,7 +1,8 @@
+import asyncio
 import json
 import os
 from string import Template
-from typing import Callable, Dict, Optional
+from typing import Callable, Dict, Optional, Type
 
 import openai
 
@@ -63,14 +64,14 @@ def __init__(
         conn_str: str,
         schema_file: Optional[str] = None,
         examples: Optional[Dict] = None,
-        embedding: Optional[EmbeddingBase] = OpenAIEmbedding,
-        vector_db: Optional[VectorDBBase] = Faiss,
-        document_store: Optional[DocumentStoreBase] = EphemeralDocumentStore,
+        embedding: Type[EmbeddingBase] = OpenAIEmbedding,
+        vector_db: Type[VectorDBBase] = Faiss,
+        document_store: Type[DocumentStoreBase] = EphemeralDocumentStore,
         rail_spec: Optional[str] = None,
         rail_params: Optional[Dict] = None,
-        example_formatter: Optional[Callable] = example_formatter,
-        reask_prompt: Optional[str] = REASK_PROMPT,
-        llm_api: Optional[Callable] = openai.Completion.create,
+        example_formatter: Callable = example_formatter,
+        reask_prompt: str = REASK_PROMPT,
+        llm_api: Callable = openai.Completion.create,
         llm_api_kwargs: Optional[Dict] = None,
         num_relevant_examples: int = 2,
     ):
@@ -119,7 +120,7 @@ def _init_guard(
         schema_file: Optional[str] = None,
         rail_spec: Optional[str] = None,
         rail_params: Optional[Dict] = None,
-        reask_prompt: Optional[str] = REASK_PROMPT,
+        reask_prompt: str = REASK_PROMPT,
     ):
         # Initialize the Guard class
         if rail_spec is None:
@@ -144,9 +145,9 @@ def _init_guard(
     def _create_docstore_with_examples(
         self,
         examples: Optional[Dict],
-        embedding: EmbeddingBase,
-        vector_db: VectorDBBase,
-        document_store: DocumentStoreBase,
+        embedding: Type[EmbeddingBase],
+        vector_db: Type[VectorDBBase],
+        document_store: Type[DocumentStoreBase],
     ) -> Optional[DocumentStoreBase]:
         if examples is None:
             return None
@@ -167,7 +168,7 @@ def _create_docstore_with_examples(
     def output_schema_formatter(output) -> str:
         return json.dumps({"generated_sql": output}, indent=4)
 
-    def __call__(self, text: str) -> str:
+    def __call__(self, text: str) -> Optional[str]:
         """Run text2sql on a text query and return the SQL query."""
 
         if self.store is not None:
@@ -179,6 +180,12 @@ def __call__(self, text: str) -> str:
         else:
             similar_examples_prompt = ""
 
+        if asyncio.iscoroutinefunction(self.llm_api):
+            raise ValueError(
+                "Async API is not supported in Text2SQL application. "
+                "Please use a synchronous API."
+            )
+
         try:
             output = self.guard(
                 self.llm_api,
@@ -188,7 +195,11 @@ def __call__(self, text: str) -> str:
                     "db_info": str(self.sql_schema),
                 },
                 **self.llm_api_kwargs,
-            )[1]["generated_sql"]
+            )[  # type: ignore
+                1
+            ][
+                "generated_sql"
+            ]
         except TypeError:
             output = None
 
diff --git a/guardrails/cli.py b/guardrails/cli.py
index 2a8d41fa7..de24c442f 100644
--- a/guardrails/cli.py
+++ b/guardrails/cli.py
@@ -12,7 +12,7 @@ def compile_rail(rail: str, out: str) -> None:
     raise NotImplementedError("Currently compiling rail is not supported.")
 
 
-def validate_llm_output(rail: str, llm_output: str) -> bool:
+def validate_llm_output(rail: str, llm_output: str) -> dict:
     """Validate guardrails.yml file."""
     guard = Guard.from_rail(rail)
     result = guard.parse(llm_output)
diff --git a/guardrails/datatypes.py b/guardrails/datatypes.py
index bc8e407c9..54a60d317 100644
--- a/guardrails/datatypes.py
+++ b/guardrails/datatypes.py
@@ -3,18 +3,17 @@
 import warnings
 from dataclasses import dataclass
 from types import SimpleNamespace
-from typing import TYPE_CHECKING, Any, Dict, Generator, Iterable
-from typing import List
+from typing import TYPE_CHECKING, Any, Dict, Iterable
 from typing import List as TypedList
-from typing import Tuple, Type, Union
+from typing import Optional, Type, TypeVar, Union
 
 from dateutil.parser import parse
 from lxml import etree as ET
-from pydantic import BaseModel
+from typing_extensions import Self
 
 from guardrails.utils.casting_utils import to_float, to_int, to_string
-from guardrails.utils.json_utils import deprecated_string_types
-from guardrails.validators import Validator
+from guardrails.utils.xml_utils import cast_xml_to_string
+from guardrails.validator_base import Validator
 
 if TYPE_CHECKING:
     from guardrails.schema import FormatAttr
@@ -22,6 +21,10 @@
 logger = logging.getLogger(__name__)
 
 
+# TODO - deprecate these altogether
+deprecated_string_types = {"sql", "email", "url", "pythoncode"}
+
+
 def update_deprecated_type_to_string(type):
     if type in deprecated_string_types:
         return "string"
@@ -37,9 +40,11 @@ class FieldValidation:
 
 
 def verify_metadata_requirements(
-    metadata: dict, datatypes: Iterable["DataType"]
-) -> List[str]:
+    metadata: dict, datatypes: Union["DataType", Iterable["DataType"]]
+) -> TypedList[str]:
     missing_keys = set()
+    if isinstance(datatypes, DataType):
+        datatypes = [datatypes]
     for datatype in datatypes:
         for validator in datatype.validators:
             for requirement in validator.required_metadata_keys:
@@ -53,15 +58,22 @@ def verify_metadata_requirements(
 
 
 class DataType:
+    rail_alias: str
+    tag: str
+
     def __init__(
         self,
         children: Dict[str, Any],
         format_attr: "FormatAttr",
-        element: ET._Element,
+        optional: bool,
+        name: Optional[str],
+        description: Optional[str],
     ) -> None:
         self._children = children
         self.format_attr = format_attr
-        self.element = element
+        self.name = name
+        self.description = description
+        self.optional = optional
 
     @property
     def validators(self) -> TypedList:
@@ -70,36 +82,7 @@ def validators(self) -> TypedList:
     def __repr__(self) -> str:
         return f"{self.__class__.__name__}({self._children})"
 
-    def __iter__(self) -> Generator[Tuple[str, "DataType", ET._Element], None, None]:
-        """Return a tuple of (name, child_data_type, child_element) for each
-        child."""
-        for el_child in self.element:
-            if "name" in el_child.attrib:
-                name: str = el_child.attrib["name"]
-                child_data_type: DataType = self._children[name]
-                yield name, child_data_type, el_child
-            else:
-                assert len(self._children) == 1, "Must have exactly one child."
-                yield None, list(self._children.values())[0], el_child
-
-    def iter(
-        self, element: ET._Element
-    ) -> Generator[Tuple[str, "DataType", ET._Element], None, None]:
-        """Iterate over the children of an element.
-
-        Yields tuples of (name, child_data_type, child_element) for each
-        child.
-        """
-        for el_child in element:
-            if element.tag == "list":
-                assert len(self._children) == 1, "Must have exactly one child."
-                yield None, list(self._children.values())[0], el_child
-            else:
-                name: str = el_child.attrib["name"]
-                child_data_type: DataType = self._children[name]
-                yield name, child_data_type, el_child
-
-    def from_str(self, s: str) -> "DataType":
+    def from_str(self, s: str) -> str:
         """Create a DataType from a string.
 
         Note: ScalarTypes like int, float, bool, etc. will override this method.
@@ -132,21 +115,30 @@ def collect_validation(
         value = self.from_str(value)
         return self._constructor_validation(key, value)
 
-    def set_children(self, element: ET._Element):
+    def set_children_from_xml(self, element: ET._Element):
         raise NotImplementedError("Abstract method.")
 
     @classmethod
-    def from_xml(cls, element: ET._Element, strict: bool = False) -> "DataType":
+    def from_xml(cls, element: ET._Element, strict: bool = False, **kwargs) -> Self:
         from guardrails.schema import FormatAttr
 
         # TODO: don't want to pass strict through to DataType,
         # but need to pass it to FormatAttr.from_element
         # how to handle this?
-        format_attr = FormatAttr.from_element(element)
-        format_attr.get_validators(strict)
+        format_attr = FormatAttr.from_element(element, cls.tag, strict)
+
+        is_optional = element.attrib.get("required", "true") == "false"
 
-        data_type = cls({}, format_attr, element)
-        data_type.set_children(element)
+        name = element.attrib.get("name")
+        if name is not None:
+            name = cast_xml_to_string(name)
+
+        description = element.attrib.get("description")
+        if description is not None:
+            description = cast_xml_to_string(description)
+
+        data_type = cls({}, format_attr, is_optional, name, description, **kwargs)
+        data_type.set_children_from_xml(element)
         return data_type
 
     @property
@@ -154,13 +146,21 @@ def children(self) -> SimpleNamespace:
         """Return a SimpleNamespace of the children of this DataType."""
         return SimpleNamespace(**self._children)
 
+    def __eq__(self, other):
+        if not isinstance(other, type(self)):
+            return False
+        return self.__dict__ == other.__dict__
+
+
+registry: Dict[str, Type[DataType]] = {}
 
-registry: Dict[str, DataType] = {}
+
+T = TypeVar("T", bound=Type[DataType])
 
 
 # Create a decorator to register a type
 def register_type(name: str):
-    def decorator(cls: type):
+    def decorator(cls: T) -> T:
         registry[name] = cls
         cls.rail_alias = name
         return cls
@@ -178,7 +178,7 @@ def deprecate_type(cls: type):
 
 
 class ScalarType(DataType):
-    def set_children(self, element: ET._Element):
+    def set_children_from_xml(self, element: ET._Element):
         for _ in element:
             raise ValueError("ScalarType data type must not have any children.")
 
@@ -191,7 +191,9 @@ class NonScalarType(DataType):
 class String(ScalarType):
     """Element tag: `<string>`"""
 
-    def from_str(self, s: str) -> "String":
+    tag = "string"
+
+    def from_str(self, s: str) -> Optional[str]:
         """Create a String from a string."""
         return to_string(s)
 
@@ -200,7 +202,9 @@ def from_str(self, s: str) -> "String":
 class Integer(ScalarType):
     """Element tag: `<integer>`"""
 
-    def from_str(self, s: str) -> "Integer":
+    tag = "integer"
+
+    def from_str(self, s: str) -> Optional[int]:
         """Create an Integer from a string."""
         return to_int(s)
 
@@ -209,7 +213,9 @@ def from_str(self, s: str) -> "Integer":
 class Float(ScalarType):
     """Element tag: `<float>`"""
 
-    def from_str(self, s: str) -> "Float":
+    tag = "float"
+
+    def from_str(self, s: str) -> Optional[float]:
         """Create a Float from a string."""
         return to_float(s)
 
@@ -218,7 +224,9 @@ def from_str(self, s: str) -> "Float":
 class Boolean(ScalarType):
     """Element tag: `<bool>`"""
 
-    def from_str(self, s: Union[str, bool]) -> "Boolean":
+    tag = "bool"
+
+    def from_str(self, s: Union[str, bool]) -> Optional[bool]:
         """Create a Boolean from a string."""
         if s is None:
             return None
@@ -242,22 +250,29 @@ class Date(ScalarType):
     element. E.g. `<date name="..." ... date-format="%Y-%m-%d" />`
     """
 
+    tag = "date"
+
     def __init__(
-        self, children: Dict[str, Any], format_attr: "FormatAttr", element: ET._Element
+        self,
+        children: Dict[str, Any],
+        format_attr: "FormatAttr",
+        optional: bool,
+        name: Optional[str],
+        description: Optional[str],
     ) -> None:
+        super().__init__(children, format_attr, optional, name, description)
         self.date_format = None
-        super().__init__(children, format_attr, element)
 
-    def from_str(self, s: str) -> "Date":
+    def from_str(self, s: str) -> Optional[datetime.date]:
         """Create a Date from a string."""
         if s is None:
             return None
-        if self.date_format is None:
+        if not self.date_format:
             return parse(s).date()
         return datetime.datetime.strptime(s, self.date_format).date()
 
     @classmethod
-    def from_xml(cls, element: ET._Element, strict: bool = False) -> "DataType":
+    def from_xml(cls, element: ET._Element, strict: bool = False) -> "Date":
         datatype = super().from_xml(element, strict)
 
         if "date-format" in element.attrib or "date_format" in element.attrib:
@@ -274,13 +289,20 @@ class Time(ScalarType):
     element. E.g. `<time name="..." ... time-format="%H:%M:%S" />`
     """
 
+    tag = "time"
+
     def __init__(
-        self, children: Dict[str, Any], format_attr: "FormatAttr", element: ET._Element
+        self,
+        children: Dict[str, Any],
+        format_attr: "FormatAttr",
+        optional: bool,
+        name: Optional[str],
+        description: Optional[str],
     ) -> None:
         self.time_format = "%H:%M:%S"
-        super().__init__(children, format_attr, element)
+        super().__init__(children, format_attr, optional, name, description)
 
-    def from_str(self, s: str) -> "Time":
+    def from_str(self, s: str) -> Optional[datetime.time]:
         """Create a Time from a string."""
         if s is None:
             return None
@@ -288,11 +310,11 @@ def from_str(self, s: str) -> "Time":
         return datetime.datetime.strptime(s, self.time_format).time()
 
     @classmethod
-    def from_xml(cls, element: ET._Element, strict: bool = False) -> "DataType":
+    def from_xml(cls, element: ET._Element, strict: bool = False) -> "Time":
         datatype = super().from_xml(element, strict)
 
         if "time-format" in element.attrib or "time_format" in element.attrib:
-            datatype.date_format = element.attrib["time-format"]
+            datatype.time_format = element.attrib["time-format"]
 
         return datatype
 
@@ -302,34 +324,46 @@ def from_xml(cls, element: ET._Element, strict: bool = False) -> "DataType":
 class Email(ScalarType):
     """Element tag: `<email>`"""
 
+    tag = "email"
+
 
 @deprecate_type
 @register_type("url")
 class URL(ScalarType):
     """Element tag: `<url>`"""
 
+    tag = "url"
+
 
 @deprecate_type
 @register_type("pythoncode")
 class PythonCode(ScalarType):
     """Element tag: `<pythoncode>`"""
 
+    tag = "pythoncode"
+
 
 @deprecate_type
 @register_type("sql")
 class SQLCode(ScalarType):
     """Element tag: `<sql>`"""
 
+    tag = "sql"
+
 
 @register_type("percentage")
 class Percentage(ScalarType):
     """Element tag: `<percentage>`"""
 
+    tag = "percentage"
+
 
 @register_type("list")
 class List(NonScalarType):
     """Element tag: `<list>`"""
 
+    tag = "list"
+
     def collect_validation(
         self,
         key: str,
@@ -352,14 +386,15 @@ def collect_validation(
 
         return validation
 
-    def set_children(self, element: ET._Element):
+    def set_children_from_xml(self, element: ET._Element):
         for idx, child in enumerate(element, start=1):
             if idx > 1:
                 # Only one child is allowed in a list data type.
                 # The child must be the datatype that all items in the list
                 # must conform to.
                 raise ValueError("List data type must have exactly one child.")
-            child_data_type = update_deprecated_type_to_string(registry[child.tag])
+            child_data_type_tag = update_deprecated_type_to_string(child.tag)
+            child_data_type = registry[child_data_type_tag]
             self._children["item"] = child_data_type.from_xml(child)
 
 
@@ -367,6 +402,8 @@ def set_children(self, element: ET._Element):
 class Object(NonScalarType):
     """Element tag: `<object>`"""
 
+    tag = "object"
+
     def collect_validation(
         self,
         key: str,
@@ -402,22 +439,45 @@ def collect_validation(
 
         return validation
 
-    def set_children(self, element: ET._Element):
+    def set_children_from_xml(self, element: ET._Element):
         for child in element:
-            child_data_type = update_deprecated_type_to_string(registry[child.tag])
-            self._children[child.attrib["name"]] = child_data_type.from_xml(child)
+            child_data_type = registry[child.tag]
+
+            name = child.attrib["name"]
+            name = cast_xml_to_string(name)
+
+            self._children[name] = child_data_type.from_xml(child)
 
 
 @register_type("choice")
 class Choice(NonScalarType):
     """Element tag: `<object>`"""
 
+    tag = "choice"
+
     def __init__(
-        self, children: Dict[str, Any], format_attr: "FormatAttr", element: ET._Element
+        self,
+        children: Dict[str, Any],
+        format_attr: "FormatAttr",
+        optional: bool,
+        name: Optional[str],
+        description: Optional[str],
+        discriminator_key: str,
     ) -> None:
-        super().__init__(children, format_attr, element)
+        super().__init__(children, format_attr, optional, name, description)
+        self.discriminator_key = discriminator_key
+
+    @classmethod
+    def from_xml(cls, element: ET._Element, strict: bool = False, **kwargs) -> Self:
         # grab `discriminator` attribute
-        self.discriminator_key = element.attrib.get("discriminator", "discriminator")
+        disc = element.attrib.get("discriminator")
+        if disc is not None:
+            disc = cast_xml_to_string(disc)
+        else:
+            disc = "discriminator"
+
+        datatype = super().from_xml(element, strict, discriminator_key=disc, **kwargs)
+        return datatype
 
     def collect_validation(
         self,
@@ -436,11 +496,15 @@ def collect_validation(
 
         return validation
 
-    def set_children(self, element: ET._Element):
+    def set_children_from_xml(self, element: ET._Element):
         for child in element:
-            child_data_type = update_deprecated_type_to_string(registry[child.tag])
+            child_data_type = registry[child.tag]
             assert child_data_type == Case
-            self._children[child.attrib["name"]] = child_data_type.from_xml(child)
+
+            name = child.attrib["name"]
+            name = cast_xml_to_string(name)
+
+            self._children[name] = child_data_type.from_xml(child)
 
     @property
     def validators(self) -> TypedList:
@@ -451,10 +515,17 @@ def validators(self) -> TypedList:
 class Case(NonScalarType):
     """Element tag: `<case>`"""
 
+    tag = "case"
+
     def __init__(
-        self, children: Dict[str, Any], format_attr: "FormatAttr", element: ET._Element
+        self,
+        children: Dict[str, Any],
+        format_attr: "FormatAttr",
+        optional: bool,
+        name: Optional[str],
+        description: Optional[str],
     ) -> None:
-        super().__init__(children, format_attr, element)
+        super().__init__(children, format_attr, optional, name, description)
 
     def collect_validation(
         self,
@@ -480,134 +551,14 @@ def collect_validation(
 
         return validation
 
-    def set_children(self, element: ET._Element):
-        for child in element:
-            child_data_type = update_deprecated_type_to_string(registry[child.tag])
-            self._children[child.attrib["name"]] = child_data_type.from_xml(child)
-
-
-@register_type("pydantic")
-class Pydantic(NonScalarType):
-    """Element tag: `<pydantic>`"""
-
-    def __init__(
-        self,
-        model: Type[BaseModel],
-        children: Dict[str, Any],
-        format_attr: "FormatAttr",
-        element: ET._Element,
-    ) -> None:
-        super().__init__(children, format_attr, element)
-        assert (
-            format_attr.empty
-        ), "The <pydantic /> data type does not support the `format` attribute."
-        assert isinstance(model, type) and issubclass(
-            model, BaseModel
-        ), "The `model` argument must be a Pydantic model."
-
-        self.model = model
-
-    @property
-    def validators(self) -> TypedList:
-        from guardrails.validators import Pydantic as PydanticValidator
-
-        # Check if the <pydantic /> element has an `on-fail` attribute.
-        # If so, use that as the `on_fail` argument for the PydanticValidator.
-        on_fail = None
-        on_fail_attr_name = "on-fail-pydantic"
-        if on_fail_attr_name in self.element.attrib:
-            on_fail = self.element.attrib[on_fail_attr_name]
-        return [PydanticValidator(self.model, on_fail=on_fail)]
-
-    def set_children(self, element: ET._Element):
+    def set_children_from_xml(self, element: ET._Element):
         for child in element:
-            child_data_type = update_deprecated_type_to_string(registry[child.tag])
-            self._children[child.attrib["name"]] = child_data_type.from_xml(child)
-
-    @classmethod
-    def from_xml(cls, element: ET._Element, strict: bool = False) -> "DataType":
-        from guardrails.schema import FormatAttr
-        from guardrails.utils.pydantic_utils import pydantic_models
-
-        model_name = element.attrib["model"]
-        model = pydantic_models.get(model_name, None)
+            child_data_type = registry[child.tag]
 
-        if model is None:
-            raise ValueError(f"Invalid Pydantic model: {model_name}")
+            name = child.attrib["name"]
+            name = cast_xml_to_string(name)
 
-        data_type = cls(model, {}, FormatAttr(), element)
-        data_type.set_children(element)
-        return data_type
-
-    def to_object_element(self) -> ET._Element:
-        """Convert the Pydantic data type to an <object /> element."""
-        from guardrails.utils.pydantic_utils import (
-            PYDANTIC_SCHEMA_TYPE_MAP,
-            get_field_descriptions,
-            pydantic_validators,
-        )
-
-        # Get the following attributes
-        # TODO: add on-fail
-        try:
-            name = self.element.attrib["name"]
-        except KeyError:
-            name = None
-        try:
-            description = self.element.attrib["description"]
-        except KeyError:
-            description = None
-
-        # Get the Pydantic model schema.
-        schema = self.model.schema()
-        field_descriptions = get_field_descriptions(self.model)
-
-        # Make the XML as follows using lxml
-        # <object name="..." description="..." format="semicolon separated root validators" pydantic="ModelName"> # noqa: E501
-        #     <type name="..." description="..." format="semicolon separated validators" /> # noqa: E501
-        # </object>
-
-        # Add the object element, opening tag
-        xml = ""
-        root_validators = "; ".join(
-            list(pydantic_validators[self.model]["__root__"].keys())
-        )
-        xml += "<object "
-        if name:
-            xml += f' name="{name}"'
-        if description:
-            xml += f' description="{description}"'
-        if root_validators:
-            xml += f' format="{root_validators}"'
-        xml += f' pydantic="{self.model.__name__}"'
-        xml += ">"
-
-        # Add all the nested fields
-        for field in schema["properties"]:
-            properties = schema["properties"][field]
-            field_type = PYDANTIC_SCHEMA_TYPE_MAP[properties["type"]]
-            field_validators = "; ".join(
-                list(pydantic_validators[self.model][field].keys())
-            )
-            try:
-                field_description = field_descriptions[field]
-            except KeyError:
-                field_description = ""
-            xml += f"<{field_type}"
-            xml += f' name="{field}"'
-            if field_description:
-                xml += f' description="{field_descriptions[field]}"'
-            if field_validators:
-                xml += f' format="{field_validators}"'
-            xml += " />"
-
-        # Close the object element
-        xml += "</object>"
-
-        # Convert the string to an XML element, making sure to format it.
-        return ET.fromstring(
-            xml, parser=ET.XMLParser(encoding="utf-8", remove_blank_text=True)
-        )
+            self._children[name] = child_data_type.from_xml(child)
 
 
 # @register_type("key")
diff --git a/guardrails/document_store.py b/guardrails/document_store.py
index 2d316a039..e81bb68a2 100644
--- a/guardrails/document_store.py
+++ b/guardrails/document_store.py
@@ -4,14 +4,14 @@
 from dataclasses import dataclass
 from typing import Any, Dict, List, Optional
 
+from pydantic import Field
+
 from guardrails.vectordb import VectorDBBase
 
 try:
-    import sqlalchemy
-    import sqlalchemy.orm as orm
+    from sqlalchemy.exc import IntegrityError
 except ImportError:
-    sqlalchemy = None
-    orm = None
+    pass
 
 
 @dataclass
@@ -25,7 +25,7 @@ class Document:
 
     id: str
     pages: Dict[int, str]
-    metadata: Dict[Any, Any] = None
+    metadata: Dict[Any, Any] = Field(default_factory=dict)
 
 
 # PageCoordinates is a datastructure that points to the location
@@ -52,6 +52,9 @@ class DocumentStoreBase(ABC):
     The store can be queried by text for similar documents.
     """
 
+    def __init__(self, vector_db: VectorDBBase, path: Optional[str] = None):
+        ...
+
     @abstractmethod
     def add_document(self, document: Document) -> None:
         """Adds a document to the store.
@@ -106,131 +109,155 @@ def flush():
         ...
 
 
-class EphemeralDocumentStore(DocumentStoreBase):
-    """EphemeralDocumentStore is a document store that stores the documents on
-    local disk and use a ephemeral vector store like Faiss."""
-
-    def __init__(self, vector_db: VectorDBBase, path: Optional[str] = None):
-        """Creates a new EphemeralDocumentStore.
-
-        Args:
-            vector_db: VectorDBBase instance to use for storing the vectors.
-            path: Path to the database file store metadata.
-        """
-        if sqlalchemy is None:
-            raise ImportError(
-                "SQLAlchemy is required for EphemeralDocumentStore"
-                "Please install it using `pip install SqlAlchemy`"
-            )
-        self._vector_db = vector_db
-        self._storage = SQLMetadataStore(path=path)
-
-    def add_document(self, document: Document):
-        # Add the document, in case the document is already there it
-        # would raise an exception and we assume the document and
-        # vectors are present.
-        try:
-            self._storage.add_docs(
-                [document], vdb_last_index=self._vector_db.last_index()
+try:
+    import sqlalchemy
+    from sqlalchemy.orm import Mapped, Session, declarative_base, mapped_column
+
+    class RealEphemeralDocumentStore(DocumentStoreBase):
+        """EphemeralDocumentStore is a document store that stores the documents
+        on local disk and use a ephemeral vector store like Faiss."""
+
+        def __init__(self, vector_db: VectorDBBase, path: Optional[str] = None):
+            """Creates a new EphemeralDocumentStore.
+
+            Args:
+                vector_db: VectorDBBase instance to use for storing the vectors.
+                path: Path to the database file store metadata.
+            """
+            self._vector_db = vector_db
+            self._storage = RealSQLMetadataStore(path=path)
+
+        def add_document(self, document: Document):
+            # Add the document, in case the document is already there it
+            # would raise an exception and we assume the document and
+            # vectors are present.
+            try:
+                self._storage.add_docs(
+                    [document], vdb_last_index=self._vector_db.last_index()
+                )
+            except IntegrityError:
+                return
+            self._vector_db.add_texts(list(document.pages.values()))
+
+        def add_text(self, text: str, meta: Dict[Any, Any]) -> str:
+            hash = hashlib.md5()
+            hash.update(text.encode("utf-8"))
+            hash.update(str(meta).encode("utf-8"))
+            id = hash.hexdigest()
+
+            doc = Document(id, {0: text}, meta)
+            self.add_document(doc)
+            return doc.id
+
+        def add_texts(self, texts: Dict[str, Dict[Any, Any]]) -> List[str]:
+            doc_ids = []
+            for text, meta in texts.items():
+                doc_id = self.add_text(text, meta)
+                doc_ids.append(doc_id)
+            return doc_ids
+
+        def search(self, query: str, k: int = 4) -> List[Page]:
+            vector_db_indexes = self._vector_db.similarity_search(query, k)
+            filtered_ids = list(filter(lambda x: x != -1, vector_db_indexes))
+            return self._storage.get_pages_for_for_indexes(filtered_ids)
+
+        def search_with_threshold(
+            self, query: str, threshold: float, k: int = 4
+        ) -> List[Page]:
+            vector_db_indexes = self._vector_db.similarity_search_with_threshold(
+                query, k, threshold
             )
-        except sqlalchemy.exc.IntegrityError:
-            return
-        self._vector_db.add_texts(list(document.pages.values()))
+            filtered_ids = list(filter(lambda x: x != -1, vector_db_indexes))
+            return self._storage.get_pages_for_for_indexes(filtered_ids)
 
-    def add_text(self, text: str, meta: Dict[Any, Any]) -> str:
-        hash = hashlib.md5()
-        hash.update(text.encode("utf-8"))
-        hash.update(str(meta).encode("utf-8"))
-        id = hash.hexdigest()
+        def flush(self, path: Optional[str] = None):
+            self._vector_db.save(path)
 
-        doc = Document(id, {0: text}, meta)
-        self.add_document(doc)
-        return doc.id
+    Base = declarative_base()
 
-    def add_texts(self, texts: Dict[str, Dict[Any, Any]]) -> List[str]:
-        doc_ids = []
-        for text, meta in texts.items():
-            doc_id = self.add_text(text, meta)
-            doc_ids.append(doc_id)
-        return doc_ids
-
-    def search(self, query: str, k: int = 4) -> List[Page]:
-        vector_db_indexes = self._vector_db.similarity_search(query, k)
-        filtered_ids = filter(lambda x: x != -1, vector_db_indexes)
-        return self._storage.get_pages_for_for_indexes(filtered_ids)
+    class RealSqlDocument(Base):
+        __tablename__ = "documents"
 
-    def search_with_threshold(
-        self, query: str, threshold: float, k: int = 4
-    ) -> List[Page]:
-        vector_db_indexes = self._vector_db.similarity_search_with_threshold(
-            query, k, threshold
-        )
-        filtered_ids = filter(lambda x: x != -1, vector_db_indexes)
-        return self._storage.get_pages_for_for_indexes(filtered_ids)
+        id: Mapped[int] = mapped_column(primary_key=True)  # type: ignore
+        page_num: Mapped[int] = mapped_column(
+            sqlalchemy.Integer, primary_key=True
+        )  # type: ignore
+        text: Mapped[str] = mapped_column(sqlalchemy.String)  # type: ignore
+        meta: Mapped[dict] = mapped_column(sqlalchemy.PickleType)  # type: ignore
+        vector_index: Mapped[int] = mapped_column(sqlalchemy.Integer)  # type: ignore
+
+    class RealSQLMetadataStore:
+        def __init__(self, path: Optional[str] = None):
+            conn = f"sqlite:///{path}" if path is not None else "sqlite://"
+            self._engine = sqlalchemy.create_engine(conn)  # type: ignore
+            RealSqlDocument.metadata.create_all(self._engine, checkfirst=True)
+
+        def add_docs(self, docs: List[Document], vdb_last_index: int):
+            vector_id = vdb_last_index
+            with Session(self._engine) as session:
+                for doc in docs:
+                    for page_num, text in doc.pages.items():
+                        session.add(
+                            RealSqlDocument(
+                                id=doc.id,
+                                page_num=page_num,
+                                text=text,
+                                meta=doc.metadata,
+                                vector_index=vector_id,
+                            )
+                        )
+                        vector_id += 1
 
-    def flush(self, path: Optional[str] = None):
-        self._vector_db.save(path)
+                session.commit()
 
+        def get_pages_for_for_indexes(self, indexes: List[int]) -> List[Page]:
+            pages: List[Page] = []
+            with Session(self._engine) as session:
+                for index in indexes:
+                    query = sqlalchemy.select(RealSqlDocument).where(
+                        RealSqlDocument.vector_index == index
+                    )
+                    sql_docs = session.execute(query)
+                    sql_doc = sql_docs.first()
+                    if sql_doc is None:
+                        continue
+                    sql_doc = sql_doc[0]
+                    pages.append(
+                        Page(
+                            PageCoordinates(sql_doc.id, sql_doc.page_num),
+                            sql_doc.text,
+                            sql_doc.meta,
+                        )
+                    )
 
-if orm is not None:
-    Base = orm.declarative_base()
+            return pages
 
-    class SqlDocument(Base):
-        __tablename__ = "documents"
+    EphemeralDocumentStore = RealEphemeralDocumentStore
+    SQLDocument = RealSqlDocument
+    SQLMetadataStore = RealSQLMetadataStore
+except ImportError:
 
-        id: orm.Mapped[int] = orm.mapped_column(primary_key=True)
-        page_num: orm.Mapped[int] = orm.mapped_column(
-            sqlalchemy.Integer, primary_key=True
-        )
-        text: orm.Mapped[str] = orm.mapped_column(sqlalchemy.String)
-        meta: orm.Mapped[dict] = orm.mapped_column(sqlalchemy.PickleType)
-        vector_index: orm.Mapped[int] = orm.mapped_column(sqlalchemy.Integer)
-
-else:
-
-    class SqlDocument:
-        pass
-
-
-class SQLMetadataStore:
-    def __init__(self, path: Optional[str] = None):
-        conn = f"sqlite:///{path}" if path is not None else "sqlite://"
-        self._engine = sqlalchemy.create_engine(conn)
-        SqlDocument.metadata.create_all(self._engine, checkfirst=True)
-
-    def add_docs(self, docs: List[Document], vdb_last_index: int):
-        vector_id = vdb_last_index
-        with orm.Session(self._engine) as session:
-            for doc in docs:
-                for page_num, text in doc.pages.items():
-                    session.add(
-                        SqlDocument(
-                            id=doc.id,
-                            page_num=page_num,
-                            text=text,
-                            meta=doc.metadata,
-                            vector_index=vector_id,
-                        )
-                    )
-                    vector_id += 1
+    class FallbackEphemeralDocumentStore:
+        def __init__(self):
+            raise ImportError(
+                "SQLAlchemy is required for EphemeralDocumentStore"
+                "Please install it using `pip install SqlAlchemy`"
+            )
 
-            session.commit()
+    class FallbackSQLDocument:
+        def __init__(self):
+            raise ImportError(
+                "SQLAlchemy is required for SQLDocument"
+                "Please install it using `pip install SqlAlchemy`"
+            )
 
-    def get_pages_for_for_indexes(self, indexes: List[int]) -> List[Page]:
-        pages: List[Page] = []
-        with orm.Session(self._engine) as session:
-            for index in indexes:
-                query = sqlalchemy.select(SqlDocument).where(
-                    SqlDocument.vector_index == index
-                )
-                sql_docs = session.execute(query)
-                sql_doc = sql_docs.first()[0]
-                pages.append(
-                    Page(
-                        PageCoordinates(sql_doc.id, sql_doc.page_num),
-                        sql_doc.text,
-                        sql_doc.meta,
-                    )
-                )
+    class FallbackSQLMetadataStore:
+        def __init__(self):
+            raise ImportError(
+                "SQLAlchemy is required for SQLMetadataStore"
+                "Please install it using `pip install SqlAlchemy`"
+            )
 
-        return pages
+    EphemeralDocumentStore = FallbackEphemeralDocumentStore
+    SQLDocument = FallbackSQLDocument
+    SQLMetadataStore = FallbackSQLMetadataStore
diff --git a/guardrails/embedding.py b/guardrails/embedding.py
index bcdbe0e88..29bdcf048 100644
--- a/guardrails/embedding.py
+++ b/guardrails/embedding.py
@@ -6,22 +6,19 @@
 
 import openai
 
-try:
-    import numpy as np
-except ImportError:
-    np = None
-
 
 class EmbeddingBase(ABC):
     """Base class for embedding models."""
 
     def __init__(
         self,
-        model: Optional[str],
-        encoding_name: Optional[str],
-        max_tokens: Optional[int],
+        model: Optional[str] = None,
+        encoding_name: Optional[str] = None,
+        max_tokens: Optional[int] = None,
     ):
-        if np is None:
+        try:
+            import numpy  # noqa: F401
+        except ImportError:
             raise ImportError(
                 f"`numpy` is required for `{self.__class__.__name__}` class."
                 "Please install it with `pip install numpy`."
@@ -54,20 +51,32 @@ def _len_safe_get_embedding(
         Returns:
             List[float] Embedding of the text.
         """
-        chunk_embeddings = []
+        try:
+            import numpy as np
+        except ImportError:
+            raise ImportError(
+                f"`numpy` is required for `{self.__class__.__name__}` class."
+                "Please install it with `pip install numpy`."
+            )
+
+        chunk_embeddings_list = []
         chunk_lens = []
 
         for chunk in EmbeddingBase._chunked_tokens(
             text=text, encoding_name=self._encoding_name, chunk_length=self._max_tokens
         ):
-            chunk_embeddings.append(embedder(chunk))
+            chunk_embeddings_list.append(embedder(chunk))
             chunk_lens.append(len(chunk))
 
         if average:
-            chunk_embeddings = np.average(chunk_embeddings, axis=0, weights=chunk_lens)
+            chunk_embeddings = np.average(
+                chunk_embeddings_list, axis=0, weights=chunk_lens
+            )
             chunk_embeddings = chunk_embeddings / np.linalg.norm(
                 chunk_embeddings
             )  # normalizes length to 1
+        else:
+            chunk_embeddings = np.array(chunk_embeddings_list)
         return chunk_embeddings.flatten().tolist()
 
     @staticmethod
@@ -93,9 +102,14 @@ def _batched(iterable, n):
         if n < 1:
             raise ValueError("n must be at least one")
         it = iter(iterable)
-        while batch := tuple(islice(it, n)):
+        while batch := list(islice(it, n)):
             yield batch
 
+    @property
+    def output_dim(self) -> int:
+        """Returns the dimension of the output of the model."""
+        raise NotImplementedError
+
 
 class OpenAIEmbedding(EmbeddingBase):
     def __init__(
@@ -114,9 +128,7 @@ def __init__(
     def embed(self, texts: List[str]) -> List[List[float]]:
         embeddings = []
         for text in texts:
-            embeddings.append(
-                super()._len_safe_get_embedding(text, self._get_embedding)
-            )
+            embeddings.append(super()._len_safe_get_embedding(text, self.embed_query))
 
         return embeddings
 
@@ -124,7 +136,7 @@ def embed_query(self, query: str) -> List[float]:
         resp = self._get_embedding([query])
         return resp[0]
 
-    def _get_embedding(self, texts: List[str]) -> List[float]:
+    def _get_embedding(self, texts: List[str]) -> List[List[float]]:
         api_key = (
             self.api_key
             if self.api_key is not None
@@ -133,10 +145,12 @@ def _get_embedding(self, texts: List[str]) -> List[float]:
         resp = openai.Embedding.create(
             api_key=api_key, model=self._model, input=texts, api_base=self.api_base
         )
-        return [r["embedding"] for r in resp["data"]]
+        return [r["embedding"] for r in resp["data"]]  # type: ignore
 
     @property
     def output_dim(self) -> int:
+        if self._model is None:
+            raise ValueError("Model not set")
         if self._model == "text-embedding-ada-002":
             return 1536
         elif "ada" in self._model:
@@ -163,7 +177,7 @@ def __init__(
         max_tokens: Optional[int] = 8191,
     ):
         try:
-            from manifest import Manifest
+            from manifest import Manifest  # type: ignore
         except ImportError:
             raise ImportError(
                 "The `manifest` package is not installed. "
@@ -188,9 +202,7 @@ def __init__(
     def embed(self, texts: List[str]) -> List[List[float]]:
         embeddings = []
         for text in texts:
-            embeddings.append(
-                super()._len_safe_get_embedding(text, self._get_embedding)
-            )
+            embeddings.append(super()._len_safe_get_embedding(text, self.embed_query))
 
         return embeddings
 
@@ -198,9 +210,9 @@ def embed_query(self, query: str) -> List[float]:
         resp = self._get_embedding([query])
         return resp[0]
 
-    def _get_embedding(self, texts: List[str]) -> List[float]:
+    def _get_embedding(self, texts: List[str]) -> List[List[float]]:
         embeddings = self._manifest.run(texts)
-        return embeddings
+        return embeddings  # type: ignore
 
     @cached_property
     def output_dim(self) -> int:
diff --git a/guardrails/guard.py b/guardrails/guard.py
index f1a20a82e..c3260438a 100644
--- a/guardrails/guard.py
+++ b/guardrails/guard.py
@@ -1,8 +1,18 @@
 import asyncio
 import contextvars
 import logging
-from string import Formatter
-from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union
+from typing import (
+    Any,
+    Awaitable,
+    Callable,
+    Dict,
+    List,
+    Optional,
+    Tuple,
+    Type,
+    Union,
+    overload,
+)
 
 from eliot import add_destinations, start_action
 from pydantic import BaseModel
@@ -13,6 +23,7 @@
 from guardrails.run import AsyncRunner, Runner
 from guardrails.schema import Schema
 from guardrails.utils.logs_utils import GuardState
+from guardrails.utils.parsing_utils import get_template_variables
 from guardrails.utils.reask_utils import sub_reasks_with_fixed_values
 from guardrails.validators import Validator
 
@@ -41,8 +52,8 @@ class Guard:
     def __init__(
         self,
         rail: Rail,
-        num_reasks: int = None,
-        base_model: Optional[BaseModel] = None,
+        num_reasks: Optional[int] = None,
+        base_model: Optional[Type[BaseModel]] = None,
     ):
         """Initialize the Guard."""
         self.rail = rail
@@ -53,7 +64,7 @@ def __init__(
         self.base_model = base_model
 
     @property
-    def input_schema(self) -> Schema:
+    def input_schema(self) -> Optional[Schema]:
         """Return the input schema."""
         return self.rail.input_schema
 
@@ -63,22 +74,22 @@ def output_schema(self) -> Schema:
         return self.rail.output_schema
 
     @property
-    def instructions(self) -> Instructions:
+    def instructions(self) -> Optional[Instructions]:
         """Return the instruction-prompt."""
         return self.rail.instructions
 
     @property
-    def prompt(self) -> Prompt:
+    def prompt(self) -> Optional[Prompt]:
         """Return the prompt."""
         return self.rail.prompt
 
     @property
-    def raw_prompt(self) -> Prompt:
+    def raw_prompt(self) -> Optional[Prompt]:
         """Return the prompt, alias for `prompt`."""
         return self.prompt
 
     @property
-    def base_prompt(self) -> str:
+    def base_prompt(self) -> Optional[str]:
         """Return the base prompt i.e. prompt.source."""
         if self.prompt is None:
             return None
@@ -90,7 +101,7 @@ def state(self) -> GuardState:
         return self.guard_state
 
     @property
-    def reask_prompt(self) -> Prompt:
+    def reask_prompt(self) -> Optional[Prompt]:
         """Return the reask prompt."""
         return self._reask_prompt
 
@@ -102,16 +113,14 @@ def reask_prompt(self, reask_prompt: Union[str, Prompt]):
             reask_prompt = Prompt(reask_prompt)
 
         # Check that the reask prompt has the correct variables
-        variables = [
-            t[1] for t in Formatter().parse(reask_prompt.source) if t[1] is not None
-        ]
+        variables = get_template_variables(reask_prompt.source)
         variable_set = set(variables)
         assert variable_set.__contains__("previous_response")
         assert variable_set.__contains__("output_schema")
         self._reask_prompt = reask_prompt
 
     @property
-    def reask_instructions(self) -> Prompt:
+    def reask_instructions(self) -> Optional[Instructions]:
         """Return the reask prompt."""
         return self._reask_instructions
 
@@ -126,7 +135,7 @@ def reask_instructions(self, reask_instructions: Union[str, Instructions]):
 
     def configure(
         self,
-        num_reasks: int = None,
+        num_reasks: Optional[int] = None,
     ):
         """Configure the Guard."""
         self.num_reasks = (
@@ -138,7 +147,7 @@ def configure(
         )
 
     @classmethod
-    def from_rail(cls, rail_file: str, num_reasks: int = None) -> "Guard":
+    def from_rail(cls, rail_file: str, num_reasks: Optional[int] = None) -> "Guard":
         """Create a Schema from a `.rail` file.
 
         Args:
@@ -151,7 +160,9 @@ def from_rail(cls, rail_file: str, num_reasks: int = None) -> "Guard":
         return cls(Rail.from_file(rail_file), num_reasks=num_reasks)
 
     @classmethod
-    def from_rail_string(cls, rail_string: str, num_reasks: int = None) -> "Guard":
+    def from_rail_string(
+        cls, rail_string: str, num_reasks: Optional[int] = None
+    ) -> "Guard":
         """Create a Schema from a `.rail` string.
 
         Args:
@@ -166,10 +177,10 @@ def from_rail_string(cls, rail_string: str, num_reasks: int = None) -> "Guard":
     @classmethod
     def from_pydantic(
         cls,
-        output_class: BaseModel,
+        output_class: Type[BaseModel],
         prompt: Optional[str] = None,
         instructions: Optional[str] = None,
-        num_reasks: int = None,
+        num_reasks: Optional[int] = None,
     ) -> "Guard":
         """Create a Guard instance from a Pydantic model and prompt."""
         rail = Rail.from_pydantic(
@@ -186,7 +197,7 @@ def from_string(
         instructions: Optional[str] = None,
         reask_prompt: Optional[str] = None,
         reask_instructions: Optional[str] = None,
-        num_reasks: int = None,
+        num_reasks: Optional[int] = None,
     ) -> "Guard":
         """Create a Guard instance for a string response with prompt,
         instructions, and validations.
@@ -210,10 +221,42 @@ def from_string(
         )
         return cls(rail, num_reasks=num_reasks)
 
+    @overload
+    def __call__(
+        self,
+        llm_api: Callable[[Any], Awaitable[Any]],
+        prompt_params: Optional[Dict] = None,
+        num_reasks: Optional[int] = None,
+        prompt: Optional[str] = None,
+        instructions: Optional[str] = None,
+        msg_history: Optional[List[Dict]] = None,
+        metadata: Optional[Dict] = None,
+        full_schema_reask: Optional[bool] = None,
+        *args,
+        **kwargs,
+    ) -> Awaitable[Tuple[str, Any]]:
+        ...
+
+    @overload
+    def __call__(
+        self,
+        llm_api: Callable,
+        prompt_params: Optional[Dict] = None,
+        num_reasks: Optional[int] = None,
+        prompt: Optional[str] = None,
+        instructions: Optional[str] = None,
+        msg_history: Optional[List[Dict]] = None,
+        metadata: Optional[Dict] = None,
+        full_schema_reask: Optional[bool] = None,
+        *args,
+        **kwargs,
+    ) -> Tuple[str, Any]:
+        ...
+
     def __call__(
         self,
         llm_api: Union[Callable, Callable[[Any], Awaitable[Any]]],
-        prompt_params: Dict = None,
+        prompt_params: Optional[Dict] = None,
         num_reasks: Optional[int] = None,
         prompt: Optional[str] = None,
         instructions: Optional[str] = None,
@@ -222,7 +265,7 @@ def __call__(
         full_schema_reask: Optional[bool] = None,
         *args,
         **kwargs,
-    ) -> Union[Tuple[str, Dict], Awaitable[Tuple[str, Dict]]]:
+    ) -> Union[Tuple[Optional[str], Any], Awaitable[Tuple[Optional[str], Any]]]:
         """Call the LLM and validate the output. Pass an async LLM API to
         return a coroutine.
 
@@ -243,15 +286,23 @@ def __call__(
         Returns:
             The raw text output from the LLM and the validated output.
         """
-        self.configure(num_reasks)
         if metadata is None:
             metadata = {}
         if full_schema_reask is None:
             full_schema_reask = self.base_model is not None
+        if prompt_params is None:
+            prompt_params = {}
 
         context = contextvars.ContextVar("kwargs")
         context.set(kwargs)
 
+        self.configure(num_reasks)
+        if self.num_reasks is None:
+            raise RuntimeError(
+                "`num_reasks` is `None` after calling `configure()`. "
+                "This should never happen."
+            )
+
         # If the LLM API is async, return a coroutine
         if asyncio.iscoroutinefunction(llm_api):
             return self._call_async(
@@ -292,12 +343,12 @@ def _call_sync(
         full_schema_reask: bool,
         *args,
         **kwargs,
-    ) -> Tuple[str, Dict]:
-        instructions = instructions or self.instructions
-        prompt = prompt or self.prompt
-        msg_history = msg_history or []
-        if prompt is None:
-            if not len(msg_history):
+    ) -> Tuple[Optional[str], Any]:
+        instructions_obj = instructions or self.instructions
+        prompt_obj = prompt or self.prompt
+        msg_history_obj = msg_history or []
+        if prompt_obj is None:
+            if msg_history is not None and not len(msg_history_obj):
                 raise RuntimeError(
                     "You must provide a prompt if msg_history is empty. "
                     "Alternatively, you can provide a prompt in the Schema constructor."
@@ -305,9 +356,9 @@ def _call_sync(
 
         with start_action(action_type="guard_call", prompt_params=prompt_params):
             runner = Runner(
-                instructions=instructions,
-                prompt=prompt,
-                msg_history=msg_history,
+                instructions=instructions_obj,
+                prompt=prompt_obj,
+                msg_history=msg_history_obj,
                 api=get_llm_ask(llm_api, *args, **kwargs),
                 input_schema=self.input_schema,
                 output_schema=self.output_schema,
@@ -334,7 +385,7 @@ async def _call_async(
         full_schema_reask: bool,
         *args,
         **kwargs,
-    ) -> Tuple[str, Dict]:
+    ) -> Tuple[Optional[str], Any]:
         """Call the LLM asynchronously and validate the output.
 
         Args:
@@ -353,20 +404,20 @@ async def _call_async(
         Returns:
             The raw text output from the LLM and the validated output.
         """
-        instructions = instructions or self.instructions
-        prompt = prompt or self.prompt
-        msg_history = msg_history or []
-        if prompt is None:
-            if not len(msg_history):
+        instructions_obj = instructions or self.instructions
+        prompt_obj = prompt or self.prompt
+        msg_history_obj = msg_history or []
+        if prompt_obj is None:
+            if msg_history_obj is not None and not len(msg_history_obj):
                 raise RuntimeError(
                     "You must provide a prompt if msg_history is empty. "
                     "Alternatively, you can provide a prompt in the RAIL spec."
                 )
         with start_action(action_type="guard_call", prompt_params=prompt_params):
             runner = AsyncRunner(
-                instructions=instructions,
-                prompt=prompt,
-                msg_history=msg_history,
+                instructions=instructions_obj,
+                prompt=prompt_obj,
+                msg_history=msg_history_obj,
                 api=get_async_llm_ask(llm_api, *args, **kwargs),
                 input_schema=self.input_schema,
                 output_schema=self.output_schema,
@@ -387,35 +438,88 @@ def __repr__(self):
     def __rich_repr__(self):
         yield "RAIL", self.rail
 
+    @overload
     def parse(
         self,
         llm_output: str,
         metadata: Optional[Dict] = None,
-        llm_api: Union[Callable, Callable[[Any], Awaitable[Any]]] = None,
-        num_reasks: int = None,
-        prompt_params: Dict = None,
-        full_schema_reask: bool = None,
+        llm_api: None = None,
+        num_reasks: int = 1,
+        prompt_params: Optional[Dict] = None,
+        full_schema_reask: Optional[bool] = None,
         *args,
         **kwargs,
-    ) -> Union[str, Dict, Awaitable[str], Awaitable[Dict]]:
+    ) -> Any:
+        ...
+
+    @overload
+    def parse(
+        self,
+        llm_output: str,
+        metadata: Optional[Dict] = None,
+        llm_api: Callable[[Any], Awaitable[Any]] = ...,
+        num_reasks: int = 1,
+        prompt_params: Optional[Dict] = None,
+        full_schema_reask: Optional[bool] = None,
+        *args,
+        **kwargs,
+    ) -> Awaitable[Any]:
+        ...
+
+    @overload
+    def parse(
+        self,
+        llm_output: str,
+        metadata: Optional[Dict] = None,
+        llm_api: Optional[Callable] = None,
+        num_reasks: int = 1,
+        prompt_params: Optional[Dict] = None,
+        full_schema_reask: Optional[bool] = None,
+        *args,
+        **kwargs,
+    ) -> Any:
+        ...
+
+    def parse(
+        self,
+        llm_output: str,
+        metadata: Optional[Dict] = None,
+        llm_api: Optional[Callable] = None,
+        num_reasks: int = 1,
+        prompt_params: Optional[Dict] = None,
+        full_schema_reask: Optional[bool] = None,
+        *args,
+        **kwargs,
+    ) -> Union[Any, Awaitable[Any]]:
         """Alternate flow to using Guard where the llm_output is known.
 
         Args:
+            llm_output: The output being parsed and validated.
+            metadata: Metadata to pass to the validators.
             llm_api: The LLM API to call
-                (e.g. openai.Completion.create or openai.Completion.acreate)
+                     (e.g. openai.Completion.create or openai.Completion.acreate)
             num_reasks: The max times to re-ask the LLM for invalid output.
+            prompt_params: The parameters to pass to the prompt.format() method.
+            full_schema_reask: When reasking, whether to regenerate the full schema
+                               or just the incorrect values.
 
         Returns:
             The validated response. This is either a string or a dictionary,
                 determined by the object schema defined in the RAILspec.
         """
-        num_reasks = (
+        final_num_reasks = (
             num_reasks if num_reasks is not None else 0 if llm_api is None else None
         )
-        self.configure(num_reasks)
+        self.configure(final_num_reasks)
+        if self.num_reasks is None:
+            raise RuntimeError(
+                "`num_reasks` is `None` after calling `configure()`. "
+                "This should never happen."
+            )
         if full_schema_reask is None:
             full_schema_reask = self.base_model is not None
         metadata = metadata or {}
+        prompt_params = prompt_params or {}
 
         context = contextvars.ContextVar("kwargs")
         context.set(kwargs)
@@ -448,13 +552,13 @@ def _sync_parse(
         self,
         llm_output: str,
         metadata: Dict,
-        llm_api: Callable = None,
-        num_reasks: int = 1,
-        prompt_params: Dict = None,
-        full_schema_reask: bool = False,
+        llm_api: Optional[Callable],
+        num_reasks: int,
+        prompt_params: Dict,
+        full_schema_reask: bool,
         *args,
         **kwargs,
-    ) -> Dict:
+    ) -> Any:
         """Alternate flow to using Guard where the llm_output is known.
 
         Args:
@@ -489,13 +593,13 @@ async def _async_parse(
         self,
         llm_output: str,
         metadata: Dict,
-        llm_api: Callable[[Any], Awaitable[Any]] = None,
-        num_reasks: int = 1,
-        prompt_params: Dict = None,
-        full_schema_reask: bool = False,
+        llm_api: Optional[Callable[[Any], Awaitable[Any]]],
+        num_reasks: int,
+        prompt_params: Dict,
+        full_schema_reask: bool,
         *args,
         **kwargs,
-    ) -> Dict:
+    ) -> Any:
         """Alternate flow to using Guard where the llm_output is known.
 
         Args:
diff --git a/guardrails/llm_providers.py b/guardrails/llm_providers.py
index b2ace5e22..47260948c 100644
--- a/guardrails/llm_providers.py
+++ b/guardrails/llm_providers.py
@@ -2,24 +2,13 @@
 from typing import Any, Awaitable, Callable, Dict, List, Optional, cast
 
 import openai
+import openai.error
 from pydantic import BaseModel
 from tenacity import retry, retry_if_exception_type, wait_exponential_jitter
 
 from guardrails.utils.logs_utils import LLMResponse
 from guardrails.utils.pydantic_utils import convert_pydantic_model_to_openai_fn
 
-try:
-    MANIFEST = True
-    import manifest
-except ImportError:
-    MANIFEST = False
-
-try:
-    import cohere
-except ImportError:
-    cohere = None
-
-
 OPENAI_RETRYABLE_ERRORS = [
     openai.error.APIConnectionError,
     openai.error.APIError,
@@ -91,13 +80,17 @@ def nonchat_prompt(prompt: str, instructions: Optional[str] = None) -> str:
 
 
 def chat_prompt(
-    prompt: str,
+    prompt: Optional[str],
     instructions: Optional[str] = None,
     msg_history: Optional[List[Dict]] = None,
 ) -> List[Dict[str, str]]:
     """Prepare final prompt for chat engine."""
     if msg_history:
         return msg_history
+    if prompt is None:
+        raise PromptCallableException(
+            "You must pass in either `text` or `msg_history` to `guard.__call__`."
+        )
 
     if not instructions:
         instructions = "You are a helpful assistant."
@@ -126,9 +119,13 @@ def _invoke_llm(
             **kwargs,
         )
         return LLMResponse(
-            output=openai_response["choices"][0]["text"],
-            prompt_token_count=openai_response["usage"]["prompt_tokens"],
-            response_token_count=openai_response["usage"]["completion_tokens"],
+            output=openai_response["choices"][0]["text"],  # type: ignore
+            prompt_token_count=openai_response["usage"][  # type: ignore
+                "prompt_tokens"
+            ],
+            response_token_count=openai_response["usage"][  # type: ignore
+                "completion_tokens"
+            ],
         )
 
 
@@ -140,7 +137,7 @@ def _invoke_llm(
         instructions: Optional[str] = None,
         msg_history: Optional[List[Dict]] = None,
         base_model: Optional[BaseModel] = None,
-        function_call: Optional[str] = None,
+        function_call: Optional[Any] = None,
         *args,
         **kwargs,
     ) -> LLMResponse:
@@ -191,17 +188,21 @@ def _invoke_llm(
         )
 
         # Extract string from response
-        if "function_call" in openai_response["choices"][0]["message"]:
-            output = openai_response["choices"][0]["message"]["function_call"][
-                "arguments"
-            ]
+        if "function_call" in openai_response["choices"][0]["message"]:  # type: ignore
+            output = openai_response["choices"][0]["message"][  # type: ignore
+                "function_call"
+            ]["arguments"]
         else:
-            output = openai_response["choices"][0]["message"]["content"]
+            output = openai_response["choices"][0]["message"]["content"]  # type: ignore
 
         return LLMResponse(
             output=output,
-            prompt_token_count=openai_response["usage"]["prompt_tokens"],
-            response_token_count=openai_response["usage"]["completion_tokens"],
+            prompt_token_count=openai_response["usage"][  # type: ignore
+                "prompt_tokens"
+            ],
+            response_token_count=openai_response["usage"][  # type: ignore
+                "completion_tokens"
+            ],
         )
 
 
@@ -225,7 +226,9 @@ def _invoke_llm(
             ...
         ```
         """
-        if not MANIFEST:
+        try:
+            import manifest  # noqa: F401 # type: ignore
+        except ImportError:
             raise PromptCallableException(
                 "The `manifest` package is not installed. "
                 "Install with `pip install manifest-ml`"
@@ -291,16 +294,27 @@ def get_llm_ask(llm_api: Callable, *args, **kwargs) -> PromptCallableBase:
         kwargs.update({"temperature": 0})
     if llm_api == openai.Completion.create:
         return OpenAICallable(*args, **kwargs)
-    elif llm_api == openai.ChatCompletion.create:
+    if llm_api == openai.ChatCompletion.create:
         return OpenAIChatCallable(*args, **kwargs)
-    elif MANIFEST and isinstance(llm_api, manifest.Manifest):
-        return ManifestCallable(*args, client=llm_api, **kwargs)
-    elif (
-        cohere
-        and isinstance(getattr(llm_api, "__self__", None), cohere.Client)
-        and getattr(llm_api, "__name__", None) == "generate"
-    ):
-        return CohereCallable(*args, client_callable=llm_api, **kwargs)
+
+    try:
+        import manifest  # noqa: F401 # type: ignore
+
+        if isinstance(llm_api, manifest.Manifest):
+            return ManifestCallable(*args, client=llm_api, **kwargs)
+    except ImportError:
+        pass
+
+    try:
+        import cohere  # noqa: F401 # type: ignore
+
+        if (
+            isinstance(getattr(llm_api, "__self__", None), cohere.Client)
+            and getattr(llm_api, "__name__", None) == "generate"
+        ):
+            return CohereCallable(*args, client_callable=llm_api, **kwargs)
+    except ImportError:
+        pass
 
     # Let the user pass in an arbitrary callable.
     return ArbitraryCallable(*args, llm_api=llm_api, **kwargs)
@@ -311,11 +325,7 @@ def get_llm_ask(llm_api: Callable, *args, **kwargs) -> PromptCallableBase:
 ###
 
 
-class AsyncPromptCallableBase:
-    def __init__(self, *args, **kwargs):
-        self.init_args = args
-        self.init_kwargs = kwargs
-
+class AsyncPromptCallableBase(PromptCallableBase):
     async def invoke_llm(
         self,
         *args,
@@ -372,9 +382,13 @@ async def invoke_llm(
             **kwargs,
         )
         return LLMResponse(
-            output=openai_response["choices"][0]["text"],
-            prompt_token_count=openai_response["usage"]["prompt_tokens"],
-            response_token_count=openai_response["usage"]["completion_tokens"],
+            output=openai_response["choices"][0]["text"],  # type: ignore
+            prompt_token_count=openai_response["usage"][  # type: ignore
+                "prompt_tokens"
+            ],
+            response_token_count=openai_response["usage"][  # type: ignore
+                "completion_tokens"
+            ],
         )
 
 
@@ -386,7 +400,7 @@ async def invoke_llm(
         instructions: Optional[str] = None,
         msg_history: Optional[List[Dict]] = None,
         base_model: Optional[BaseModel] = None,
-        function_call: Optional[str] = None,
+        function_call: Optional[Any] = None,
         *args,
         **kwargs,
     ) -> LLMResponse:
@@ -437,17 +451,21 @@ async def invoke_llm(
         )
 
         # Extract string from response
-        if "function_call" in openai_response["choices"][0]["message"]:
-            output = openai_response["choices"][0]["message"]["function_call"][
-                "arguments"
-            ]
+        if "function_call" in openai_response["choices"][0]["message"]:  # type: ignore
+            output = openai_response["choices"][0]["message"][  # type: ignore
+                "function_call"
+            ]["arguments"]
         else:
-            output = openai_response["choices"][0]["message"]["content"]
+            output = openai_response["choices"][0]["message"]["content"]  # type: ignore
 
         return LLMResponse(
             output=output,
-            prompt_token_count=openai_response["usage"]["prompt_tokens"],
-            response_token_count=openai_response["usage"]["completion_tokens"],
+            prompt_token_count=openai_response["usage"][  # type: ignore
+                "prompt_tokens"
+            ],
+            response_token_count=openai_response["usage"][  # type: ignore
+                "completion_tokens"
+            ],
         )
 
 
@@ -471,17 +489,21 @@ async def invoke_llm(
             ...
         ```
         """
-        if not MANIFEST:
+        try:
+            import manifest  # noqa: F401 # type: ignore
+        except ImportError:
             raise PromptCallableException(
                 "The `manifest` package is not installed. "
                 "Install with `pip install manifest-ml`"
             )
         client = cast(manifest.Manifest, client)
-        manifest_response = await client.run(
-            nonchat_prompt(prompt=text, instructions=instructions), *args, **kwargs
+        manifest_response = await client.arun_batch(
+            prompts=[nonchat_prompt(prompt=text, instructions=instructions)],
+            *args,
+            **kwargs,
         )
         return LLMResponse(
-            output=manifest_response,
+            output=manifest_response[0],
         )
 
 
@@ -508,12 +530,20 @@ async def invoke_llm(self, *args, **kwargs) -> LLMResponse:
         )
 
 
-def get_async_llm_ask(llm_api: Callable[[Any], Awaitable[Any]], *args, **kwargs):
+def get_async_llm_ask(
+    llm_api: Callable[[Any], Awaitable[Any]], *args, **kwargs
+) -> AsyncPromptCallableBase:
     if llm_api == openai.Completion.acreate:
         return AsyncOpenAICallable(*args, **kwargs)
-    elif llm_api == openai.ChatCompletion.acreate:
+    if llm_api == openai.ChatCompletion.acreate:
         return AsyncOpenAIChatCallable(*args, **kwargs)
-    elif MANIFEST and isinstance(llm_api, manifest.Manifest):
-        return AsyncManifestCallable(*args, client=llm_api, **kwargs)
+
+    try:
+        import manifest  # noqa: F401 # type: ignore
+
+        if isinstance(llm_api, manifest.Manifest):
+            return AsyncManifestCallable(*args, client=llm_api, **kwargs)
+    except ImportError:
+        pass
 
     return AsyncArbitraryCallable(*args, llm_api=llm_api, **kwargs)
diff --git a/guardrails/prompt/base_prompt.py b/guardrails/prompt/base_prompt.py
index f3c568123..643313eff 100644
--- a/guardrails/prompt/base_prompt.py
+++ b/guardrails/prompt/base_prompt.py
@@ -1,13 +1,14 @@
 """Class for representing a prompt entry."""
 import re
 import warnings
-from string import Formatter, Template
+from string import Template
 from typing import Optional
 
 import regex
 
 from guardrails.namespace_template import NamespaceTemplate
 from guardrails.utils.constants import constants
+from guardrails.utils.parsing_utils import get_template_variables
 
 
 class BasePrompt:
@@ -37,7 +38,7 @@ def __str__(self) -> str:
 
     @property
     def variable_names(self):
-        return [x[1] for x in Formatter().parse(self.escape()) if x[1] is not None]
+        return get_template_variables(self.source)
 
     @property
     def format_instructions(self):
@@ -111,6 +112,8 @@ def get_format_instructions_idx(self, text: str) -> Optional[int]:
         if earliest_match_idx is None:
             return 0
 
+        if earliest_match is None:
+            return None
         return earliest_match.start()
 
     def escape(self) -> str:
diff --git a/guardrails/prompt/instructions.py b/guardrails/prompt/instructions.py
index 938f0a12e..f9bd612b5 100644
--- a/guardrails/prompt/instructions.py
+++ b/guardrails/prompt/instructions.py
@@ -1,7 +1,9 @@
 """Instructions to the LLM, to be passed in the prompt."""
-from string import Formatter, Template
+from string import Template
 from warnings import warn
 
+from guardrails.utils.parsing_utils import get_template_variables
+
 from .base_prompt import BasePrompt
 
 
@@ -26,7 +28,7 @@ def __eq__(self, __value: object) -> bool:
     def format(self, **kwargs):
         """Format the prompt using the given keyword arguments."""
         # Only use the keyword arguments that are present in the prompt.
-        vars = [x[1] for x in Formatter().parse(self.source) if x[1] is not None]
+        vars = get_template_variables(self.source)
         filtered_kwargs = {k: v for k, v in kwargs.items() if k in vars}
         if len(filtered_kwargs) == 0:
             warn(
diff --git a/guardrails/prompt/prompt.py b/guardrails/prompt/prompt.py
index 65277a5b5..20947b767 100644
--- a/guardrails/prompt/prompt.py
+++ b/guardrails/prompt/prompt.py
@@ -1,6 +1,8 @@
 """The LLM prompt."""
 import warnings
-from string import Formatter, Template
+from string import Template
+
+from guardrails.utils.parsing_utils import get_template_variables
 
 from .base_prompt import BasePrompt
 
@@ -17,7 +19,7 @@ def __eq__(self, __value: object) -> bool:
     def format(self, **kwargs):
         """Format the prompt using the given keyword arguments."""
         # Only use the keyword arguments that are present in the prompt.
-        vars = [x[1] for x in Formatter().parse(self.source) if x[1] is not None]
+        vars = get_template_variables(self.source)
         filtered_kwargs = {k: v for k, v in kwargs.items() if k in vars}
         if len(filtered_kwargs) == 0:
             warnings.warn(
diff --git a/guardrails/rail.py b/guardrails/rail.py
index 170c9a68b..fcded5170 100644
--- a/guardrails/rail.py
+++ b/guardrails/rail.py
@@ -13,6 +13,7 @@
     attach_validators_to_element,
     create_xml_element_for_base_model,
 )
+from guardrails.utils.xml_utils import cast_xml_to_string
 from guardrails.validators import Validator
 
 # TODO: Logging
@@ -33,16 +34,16 @@ class Rail:
         4. `<instructions>`, which contains the instructions to be passed to the LLM
     """
 
-    input_schema: Optional[Schema] = (None,)
-    output_schema: Optional[Schema] = (None,)
-    instructions: Optional[Instructions] = (None,)
-    prompt: Optional[Prompt] = (None,)
-    version: Optional[str] = ("0.1",)
+    input_schema: Optional[Schema]
+    output_schema: Schema
+    instructions: Optional[Instructions]
+    prompt: Optional[Prompt]
+    version: str = "0.1"
 
     @classmethod
     def from_pydantic(
         cls,
-        output_class: BaseModel,
+        output_class: Type[BaseModel],
         prompt: Optional[str] = None,
         instructions: Optional[str] = None,
         reask_prompt: Optional[str] = None,
@@ -79,7 +80,7 @@ def from_xml(cls, xml: ET._Element):
         raw_input_schema = xml.find("input")
         if raw_input_schema is None:
             # No input schema, so do no input checking.
-            input_schema = Schema()
+            input_schema = None
         else:
             input_schema = cls.load_input_schema(raw_input_schema)
 
@@ -116,12 +117,16 @@ def from_xml(cls, xml: ET._Element):
         else:
             prompt = cls.load_prompt(prompt, output_schema)
 
+        # Get version
+        version = xml.attrib["version"]
+        version = cast_xml_to_string(version)
+
         return cls(
             input_schema=input_schema,
             output_schema=output_schema,
             instructions=instructions,
             prompt=prompt,
-            version=xml.attrib["version"],
+            version=version,
         )
 
     @classmethod
@@ -148,13 +153,13 @@ def from_string_validators(
     def load_schema(root: ET._Element) -> Schema:
         """Given the RAIL <input> or <output> element, create a Schema
         object."""
-        return Schema(root)
+        return Schema.from_element(root)
 
     @staticmethod
     def load_input_schema(root: ET._Element) -> Schema:
         """Given the RAIL <input> element, create a Schema object."""
         # Recast the schema as an InputSchema.
-        return Schema(root)
+        return Schema.from_element(root)
 
     @staticmethod
     def load_output_schema(
@@ -174,12 +179,12 @@ def load_output_schema(
         """
         # If root contains a `type="string"` attribute, then it's a StringSchema
         if "type" in root.attrib and root.attrib["type"] == "string":
-            return StringSchema(
+            return StringSchema.from_element(
                 root,
                 reask_prompt_template=reask_prompt,
                 reask_instructions_template=reask_instructions,
             )
-        return JsonSchema(
+        return JsonSchema.from_element(
             root,
             reask_prompt_template=reask_prompt,
             reask_instructions_template=reask_instructions,
@@ -189,7 +194,7 @@ def load_output_schema(
     def load_instructions(root: ET._Element, output_schema: Schema) -> Instructions:
         """Given the RAIL <instructions> element, create Instructions."""
         return Instructions(
-            source=root.text,
+            source=root.text or "",
             output_schema=output_schema.transpile(),
         )
 
@@ -197,13 +202,13 @@ def load_instructions(root: ET._Element, output_schema: Schema) -> Instructions:
     def load_prompt(root: ET._Element, output_schema: Schema) -> Prompt:
         """Given the RAIL <prompt> element, create a Prompt object."""
         return Prompt(
-            source=root.text,
+            source=root.text or "",
             output_schema=output_schema.transpile(),
         )
 
 
 def generate_xml_code(
-    prompt: str,
+    prompt: Optional[str] = None,
     output_class: Optional[Type[BaseModel]] = None,
     instructions: Optional[str] = None,
     reask_prompt: Optional[str] = None,
@@ -214,7 +219,7 @@ def generate_xml_code(
     """Generate XML RAIL Spec from a pydantic model and a prompt.
 
     Parameters: Arguments:
-        prompt (str): The prompt for this RAIL spec.
+        prompt (str, optional): The prompt for this RAIL spec.
         output_class (BaseModel, optional): The Pydantic model that represents the desired output schema.  Do not specify if using a string schema. Defaults to None.
         instructions (str, optional): Instructions for chat models. Defaults to None.
         reask_prompt (str, optional): An alternative prompt to use during reasks. Defaults to None.
@@ -240,8 +245,10 @@ def generate_xml_code(
         # Create XML elements for the output_class
         create_xml_element_for_base_model(output_class, output_element)
     else:
-        attach_validators_to_element(output_element, validators)
-        output_element.set("description", description)
+        if validators is not None:
+            attach_validators_to_element(output_element, validators)
+        if description is not None:
+            output_element.set("description", description)
         output_element.set("type", "string")
 
     if prompt is not None:
diff --git a/guardrails/run.py b/guardrails/run.py
index 39b9cb21b..d052327fe 100644
--- a/guardrails/run.py
+++ b/guardrails/run.py
@@ -1,7 +1,6 @@
 import copy
 import logging
-from dataclasses import dataclass, field
-from typing import Any, Callable, Dict, List, Optional, Tuple, Union
+from typing import Any, Dict, List, Optional, Sequence, Tuple, Type, Union
 
 from eliot import add_destinations, start_action
 from pydantic import BaseModel
@@ -24,7 +23,6 @@
 add_destinations(actions_logger.debug)
 
 
-@dataclass
 class Runner:
     """Runner class that calls an LLM API with a prompt, and performs input and
     output validation.
@@ -44,55 +42,71 @@ class Runner:
         guard_history: The guard history to use, defaults to an empty history.
     """
 
-    instructions: Optional[Instructions]
-    prompt: Prompt
-    msg_history: Optional[List[Dict]]
-    api: PromptCallableBase
-    input_schema: Schema
-    output_schema: Schema
-    guard_state: GuardState
-    num_reasks: int = 0
-    metadata: Dict[str, Any] = field(default_factory=dict)
-    output: str = None
-    reask_prompt: Optional[Prompt] = None
-    reask_instructions: Optional[Instructions] = None
-    guard_history: GuardHistory = field(
-        default_factory=lambda: GuardHistory(history=[])
-    )
-    base_model: Optional[BaseModel] = None
-    full_schema_reask: bool = False
-
-    def _reset_guard_history(self):
-        """Reset the guard history."""
-        self.guard_history = GuardHistory(history=[])
-        self.guard_state.push(self.guard_history)
+    def __init__(
+        self,
+        output_schema: Schema,
+        guard_state: GuardState,
+        num_reasks: int,
+        prompt: Optional[Union[str, Prompt]] = None,
+        instructions: Optional[Union[str, Instructions]] = None,
+        msg_history: Optional[List[Dict]] = None,
+        api: Optional[PromptCallableBase] = None,
+        input_schema: Optional[Schema] = None,
+        metadata: Optional[Dict[str, Any]] = None,
+        output: Optional[str] = None,
+        reask_prompt: Optional[Prompt] = None,
+        reask_instructions: Optional[Instructions] = None,
+        guard_history: Optional[GuardHistory] = None,
+        base_model: Optional[Type[BaseModel]] = None,
+        full_schema_reask: bool = False,
+    ):
+        if prompt:
+            assert api, "Must provide an API if a prompt is provided."
+            assert not output, "Cannot provide both a prompt and output."
 
-    def __post_init__(self):
-        if self.prompt:
-            assert self.api, "Must provide an API if a prompt is provided."
-            assert not self.output, "Cannot provide both a prompt and output."
+        if isinstance(prompt, str):
+            self.prompt = Prompt(prompt, output_schema=output_schema.transpile())
+        else:
+            self.prompt = prompt
 
-        if isinstance(self.prompt, str):
-            self.prompt = Prompt(
-                self.prompt, output_schema=self.output_schema.transpile()
-            )
-
-        if isinstance(self.instructions, str):
+        if isinstance(instructions, str):
             self.instructions = Instructions(
-                self.instructions, output_schema=self.output_schema.transpile()
+                instructions, output_schema=output_schema.transpile()
             )
+        else:
+            self.instructions = instructions
 
-        if self.msg_history is not None and len(self.msg_history):
-            self.msg_history = copy.deepcopy(self.msg_history)
-            msg_history = []
-            for msg in self.msg_history:
+        if msg_history:
+            msg_history = copy.deepcopy(msg_history)
+            msg_history_copy = []
+            for msg in msg_history:
                 msg["content"] = Prompt(
-                    msg["content"], output_schema=self.output_schema.transpile()
+                    msg["content"], output_schema=output_schema.transpile()
                 )
-                msg_history.append(msg)
-            self.msg_history = msg_history
+                msg_history_copy.append(msg)
+            self.msg_history = msg_history_copy
+        else:
+            self.msg_history = None
+
+        self.api = api
+        self.input_schema = input_schema
+        self.output_schema = output_schema
+        self.guard_state = guard_state
+        self.num_reasks = num_reasks
+        self.metadata = metadata or {}
+        self.output = output
+        self.reask_prompt = reask_prompt
+        self.reask_instructions = reask_instructions
+        self.guard_history = guard_history or GuardHistory(history=[])
+        self.base_model = base_model
+        self.full_schema_reask = full_schema_reask
 
-    def __call__(self, prompt_params: Dict = None) -> GuardHistory:
+    def _reset_guard_history(self):
+        """Reset the guard history."""
+        self.guard_history = GuardHistory(history=[])
+        self.guard_state.push(self.guard_history)
+
+    def __call__(self, prompt_params: Optional[Dict] = None) -> GuardHistory:
         """Execute the runner by repeatedly calling step until the reask budget
         is exhausted.
 
@@ -103,9 +117,12 @@ def __call__(self, prompt_params: Dict = None) -> GuardHistory:
         Returns:
             The guard history.
         """
+        if prompt_params is None:
+            prompt_params = {}
+
         # check if validator requirements are fulfilled
         missing_keys = verify_metadata_requirements(
-            self.metadata, self.output_schema.to_dict().values()
+            self.metadata, self.output_schema.root_datatype
         )
         if missing_keys:
             raise ValueError(
@@ -158,8 +175,8 @@ def __call__(self, prompt_params: Dict = None) -> GuardHistory:
                     reasks,
                     validated_output,
                     output_schema,
-                    include_instructions=include_instructions,
                     prompt_params=prompt_params,
+                    include_instructions=include_instructions,
                 )
 
             return self.guard_history
@@ -167,14 +184,14 @@ def __call__(self, prompt_params: Dict = None) -> GuardHistory:
     def step(
         self,
         index: int,
-        api: PromptCallableBase,
+        api: Optional[PromptCallableBase],
         instructions: Optional[Instructions],
         prompt: Optional[Prompt],
         msg_history: Optional[List[Dict]],
         prompt_params: Dict,
-        input_schema: Schema,
+        input_schema: Optional[Schema],
         output_schema: Schema,
-        output: str = None,
+        output: Optional[str] = None,
     ):
         guard_logs = GuardLogs()
         self.guard_history.push(guard_logs)
@@ -215,10 +232,10 @@ def step(
             )
 
             guard_logs.llm_response = llm_response
-            output = llm_response.output
+            raw_output = llm_response.output
 
             # Parse: parse the output.
-            parsed_output, parsing_error = self.parse(index, output, output_schema)
+            parsed_output, parsing_error = self.parse(index, raw_output, output_schema)
 
             guard_logs.parsed_output = parsed_output
 
@@ -253,19 +270,22 @@ def prepare(
         self,
         index: int,
         instructions: Optional[Instructions],
-        prompt: Prompt,
+        prompt: Optional[Prompt],
         msg_history: Optional[List[Dict]],
         prompt_params: Dict,
-        api: Union[PromptCallableBase, AsyncPromptCallableBase],
-        input_schema: Schema,
+        api: Optional[Union[PromptCallableBase, AsyncPromptCallableBase]],
+        input_schema: Optional[Schema],
         output_schema: Schema,
-    ) -> Tuple[Instructions, Prompt, List[Dict]]:
+    ) -> Tuple[Optional[Instructions], Optional[Prompt], Optional[List[Dict]]]:
         """Prepare by running pre-processing and input validation.
 
         Returns:
             The instructions, prompt, and message history.
         """
         with start_action(action_type="prepare", index=index) as action:
+            if api is None:
+                raise ValueError("API must be provided.")
+
             if prompt_params is None:
                 prompt_params = {}
 
@@ -276,7 +296,7 @@ def prepare(
                     msg["content"] = msg["content"].format(**prompt_params)
 
                 prompt, instructions = None, None
-            else:
+            elif prompt is not None:
                 if isinstance(prompt, str):
                     prompt = Prompt(prompt)
 
@@ -290,6 +310,8 @@ def prepare(
                 instructions, prompt = output_schema.preprocess_prompt(
                     api, instructions, prompt
                 )
+            else:
+                raise ValueError("Prompt or message history must be provided.")
 
             action.log(
                 message_type="info",
@@ -305,9 +327,9 @@ def call(
         self,
         index: int,
         instructions: Optional[Instructions],
-        prompt: Prompt,
+        prompt: Optional[Prompt],
         msg_history: Optional[List[Dict[str, str]]],
-        api: Callable,
+        api: Optional[PromptCallableBase],
         output: Optional[str] = None,
     ) -> LLMResponse:
         """Run a step.
@@ -317,45 +339,38 @@ def call(
         3. Log the output
         """
 
-        def msg_history_source(msg_history) -> List[Dict[str, str]]:
-            msg_history_copy = copy.deepcopy(msg_history)
-            for msg in msg_history_copy:
-                msg["content"] = msg["content"].source
-            return msg_history_copy
-
         with start_action(action_type="call", index=index, prompt=prompt) as action:
-            llm_response = None
-            try:
-                if msg_history:
+            if output is not None:
+                llm_response = LLMResponse(
+                    output=output,
+                )
+            elif api is None:
+                raise ValueError("API or output must be provided.")
+            elif msg_history:
+                try:
                     llm_response = api(
                         msg_history=msg_history_source(msg_history),
                         base_model=self.base_model,
                     )
-                else:
-                    if prompt and instructions:
-                        llm_response = api(
-                            prompt.source,
-                            instructions=instructions.source,
-                            base_model=self.base_model,
-                        )
-                    elif prompt:
-                        llm_response = api(prompt.source, base_model=self.base_model)
-            except Exception:
-                # If the API call fails, try calling again without the base model.
-                if msg_history:
+                except Exception:
+                    # If the API call fails, try calling again without the base model.
                     llm_response = api(msg_history=msg_history_source(msg_history))
-                else:
-                    if prompt and instructions:
-                        llm_response = api(
-                            prompt.source, instructions=instructions.source
-                        )
-                    elif prompt:
-                        llm_response = api(prompt.source)
-
-            if llm_response is None:
-                llm_response = LLMResponse(
-                    output=output,
-                )
+            elif prompt and instructions:
+                try:
+                    llm_response = api(
+                        prompt.source,
+                        instructions=instructions.source,
+                        base_model=self.base_model,
+                    )
+                except Exception:
+                    llm_response = api(prompt.source, instructions=instructions.source)
+            elif prompt:
+                try:
+                    llm_response = api(prompt.source, base_model=self.base_model)
+                except Exception:
+                    llm_response = api(prompt.source)
+            else:
+                raise ValueError("Prompt or message history must be provided.")
 
             action.log(
                 message_type="info",
@@ -420,7 +435,7 @@ def introspect(
 
             return reasks
 
-    def do_loop(self, index: int, reasks: List[ReAsk]) -> bool:
+    def do_loop(self, index: int, reasks: Sequence[ReAsk]) -> bool:
         """Determine if we should loop again."""
         if reasks and index < self.num_reasks:
             return True
@@ -429,11 +444,11 @@ def do_loop(self, index: int, reasks: List[ReAsk]) -> bool:
     def prepare_to_loop(
         self,
         reasks: list,
-        validated_output: Optional[Dict],
+        validated_output: Optional[Union[Dict, ReAsk]],
         output_schema: Schema,
+        prompt_params: Dict,
         include_instructions: bool = False,
-        prompt_params: Dict = None,
-    ) -> Tuple[Prompt, Instructions, Schema, Optional[List[Dict]]]:
+    ) -> Tuple[Prompt, Optional[Instructions], Schema, Optional[List[Dict]]]:
         """Prepare to loop again."""
         output_schema, prompt, instructions = output_schema.get_reask_setup(
             reasks=reasks,
@@ -448,9 +463,44 @@ def prepare_to_loop(
 
 
 class AsyncRunner(Runner):
-    api: AsyncPromptCallableBase
+    def __init__(
+        self,
+        output_schema: Schema,
+        guard_state: GuardState,
+        num_reasks: int,
+        prompt: Optional[Union[str, Prompt]] = None,
+        instructions: Optional[Union[str, Instructions]] = None,
+        msg_history: Optional[List[Dict]] = None,
+        api: Optional[AsyncPromptCallableBase] = None,
+        input_schema: Optional[Schema] = None,
+        metadata: Optional[Dict[str, Any]] = None,
+        output: Optional[str] = None,
+        reask_prompt: Optional[Prompt] = None,
+        reask_instructions: Optional[Instructions] = None,
+        guard_history: Optional[GuardHistory] = None,
+        base_model: Optional[Type[BaseModel]] = None,
+        full_schema_reask: bool = False,
+    ):
+        super().__init__(
+            output_schema=output_schema,
+            guard_state=guard_state,
+            num_reasks=num_reasks,
+            prompt=prompt,
+            instructions=instructions,
+            msg_history=msg_history,
+            api=api,
+            input_schema=input_schema,
+            metadata=metadata,
+            output=output,
+            reask_prompt=reask_prompt,
+            reask_instructions=reask_instructions,
+            guard_history=guard_history,
+            base_model=base_model,
+            full_schema_reask=full_schema_reask,
+        )
+        self.api: Optional[AsyncPromptCallableBase] = api
 
-    async def async_run(self, prompt_params: Dict = None) -> GuardHistory:
+    async def async_run(self, prompt_params: Optional[Dict] = None) -> GuardHistory:
         """Execute the runner by repeatedly calling step until the reask budget
         is exhausted.
 
@@ -461,11 +511,13 @@ async def async_run(self, prompt_params: Dict = None) -> GuardHistory:
         Returns:
             The guard history.
         """
+        if prompt_params is None:
+            prompt_params = {}
         self._reset_guard_history()
 
         # check if validator requirements are fulfilled
         missing_keys = verify_metadata_requirements(
-            self.metadata, self.output_schema.to_dict().values()
+            self.metadata, self.output_schema.root_datatype
         )
         if missing_keys:
             raise ValueError(
@@ -519,14 +571,14 @@ async def async_run(self, prompt_params: Dict = None) -> GuardHistory:
     async def async_step(
         self,
         index: int,
-        api: AsyncPromptCallableBase,
+        api: Optional[AsyncPromptCallableBase],
         instructions: Optional[Instructions],
-        prompt: Prompt,
+        prompt: Optional[Prompt],
         msg_history: Optional[List[Dict]],
         prompt_params: Dict,
-        input_schema: Schema,
+        input_schema: Optional[Schema],
         output_schema: Schema,
-        output: str = None,
+        output: Optional[str] = None,
     ):
         guard_logs = GuardLogs()
         self.guard_history.push(guard_logs)
@@ -541,7 +593,11 @@ async def async_step(
             output_schema=output_schema,
         ):
             # Prepare: run pre-processing, and input validation.
-            if not output:
+            if output:
+                instructions = None
+                prompt = None
+                msg_history = None
+            else:
                 instructions, prompt, msg_history = self.prepare(
                     index,
                     instructions,
@@ -552,9 +608,6 @@ async def async_step(
                     input_schema,
                     output_schema,
                 )
-            else:
-                instructions = None
-                prompt = None
 
             guard_logs.prompt = prompt
             guard_logs.instructions = instructions
@@ -603,9 +656,9 @@ async def async_call(
         self,
         index: int,
         instructions: Optional[Instructions],
-        prompt: Prompt,
+        prompt: Optional[Prompt],
         msg_history: Optional[List[Dict]],
-        api: AsyncPromptCallableBase,
+        api: Optional[AsyncPromptCallableBase],
         output: Optional[str] = None,
     ) -> LLMResponse:
         """Run a step.
@@ -615,40 +668,41 @@ async def async_call(
         3. Log the output
         """
         with start_action(action_type="call", index=index, prompt=prompt) as action:
-            llm_response = None
-            try:
-                if msg_history:
-                    llm_response = await api(
-                        msg_history=msg_history,
-                        base_model=self.base_model,
-                    )
-                else:
-                    if prompt and instructions:
-                        llm_response = await api(
-                            prompt.source,
-                            instructions=instructions.source,
-                            base_model=self.base_model,
-                        )
-                    elif prompt:
-                        llm_response = await api(
-                            prompt.source, base_model=self.base_model
-                        )
-            except Exception:
-                # If the API call fails, try calling again without the base model.
-                if msg_history:
-                    llm_response = await api(msg_history=msg_history)
-                else:
-                    if prompt and instructions:
-                        llm_response = await api(
-                            prompt.source, instructions=instructions.source
-                        )
-                    elif prompt:
-                        llm_response = await api(prompt.source)
-
-            if llm_response is None:
+            if output is not None:
                 llm_response = LLMResponse(
                     output=output,
                 )
+            elif api is None:
+                raise ValueError("Either API or output must be provided.")
+            elif msg_history:
+                try:
+                    llm_response = await api(
+                        msg_history=msg_history_source(msg_history),
+                        base_model=self.base_model,
+                    )
+                except Exception:
+                    # If the API call fails, try calling again without the base model.
+                    llm_response = await api(
+                        msg_history=msg_history_source(msg_history)
+                    )
+            elif prompt and instructions:
+                try:
+                    llm_response = await api(
+                        prompt.source,
+                        instructions=instructions.source,
+                        base_model=self.base_model,
+                    )
+                except Exception:
+                    llm_response = await api(
+                        prompt.source, instructions=instructions.source
+                    )
+            elif prompt:
+                try:
+                    llm_response = await api(prompt.source, base_model=self.base_model)
+                except Exception:
+                    llm_response = await api(prompt.source)
+            else:
+                raise ValueError("Output, prompt or message history must be provided.")
 
             action.log(
                 message_type="info",
@@ -676,3 +730,10 @@ async def async_validate(
             )
 
             return validated_output
+
+
+def msg_history_source(msg_history) -> List[Dict[str, str]]:
+    msg_history_copy = copy.deepcopy(msg_history)
+    for msg in msg_history_copy:
+        msg["content"] = msg["content"].source
+    return msg_history_copy
diff --git a/guardrails/schema.py b/guardrails/schema.py
index 1ac91a3ff..9d493308d 100644
--- a/guardrails/schema.py
+++ b/guardrails/schema.py
@@ -4,15 +4,14 @@
 import re
 import warnings
 from copy import deepcopy
-from dataclasses import dataclass
-from string import Formatter
-from types import SimpleNamespace
-from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union
 
+import pydantic
 from lxml import etree as ET
+from typing_extensions import Self
 
 from guardrails import validator_service
-from guardrails.datatypes import DataType, String
+from guardrails.datatypes import Choice, DataType, Object, String
 from guardrails.llm_providers import (
     AsyncOpenAICallable,
     AsyncOpenAIChatCallable,
@@ -27,17 +26,17 @@
     verify_schema_against_json,
 )
 from guardrails.utils.logs_utils import FieldValidationLogs, GuardLogs
+from guardrails.utils.parsing_utils import get_template_variables
 from guardrails.utils.reask_utils import (
     FieldReAsk,
     NonParseableReAsk,
     SkeletonReAsk,
     gather_reasks,
     get_pruned_tree,
-    get_reasks_by_element,
     prune_obj_for_reasking,
 )
-from guardrails.validator_service import FieldValidation
-from guardrails.validators import (
+from guardrails.utils.xml_utils import cast_xml_to_string
+from guardrails.validator_base import (
     FailResult,
     Validator,
     check_refrain_in_dict,
@@ -50,8 +49,7 @@
 logger = logging.getLogger(__name__)
 
 
-@dataclass
-class FormatAttr:
+class FormatAttr(pydantic.BaseModel):
     """Class for parsing and manipulating the `format` attribute of an element.
 
     The format attribute is a string that contains semi-colon separated
@@ -67,11 +65,23 @@ class FormatAttr:
     - "is-in: {1 + 2} {2 + 3} {3 + 4}"
     """
 
+    class Config:
+        arbitrary_types_allowed = True
+
     # The format attribute string.
-    format: Optional[str] = None
+    format: Optional[str]
+
+    # The on-fail handlers.
+    on_fail_handlers: Dict[str, str]
+
+    # The validator arguments.
+    validator_args: Dict[str, List[Any]]
+
+    # The validators.
+    validators: List[Validator]
 
-    # The XML element that this format attribute is associated with.
-    element: Optional[ET._Element] = None
+    # The unregistered validators.
+    unregistered_validators: List[str]
 
     @property
     def empty(self) -> bool:
@@ -79,7 +89,9 @@ def empty(self) -> bool:
         return self.format is None
 
     @classmethod
-    def from_element(cls, element: ET._Element) -> "FormatAttr":
+    def from_element(
+        cls, element: ET._Element, tag: str, strict: bool = False
+    ) -> "FormatAttr":
         """Create a FormatAttr object from an XML element.
 
         Args:
@@ -88,27 +100,43 @@ def from_element(cls, element: ET._Element) -> "FormatAttr":
         Returns:
             A FormatAttr object.
         """
-        return cls(element.get("format"), element)
+        format_str = element.get("format")
+        if format_str is None:
+            return cls(
+                format=None,
+                on_fail_handlers={},
+                validator_args={},
+                validators=[],
+                unregistered_validators=[],
+            )
 
-    @property
-    def tokens(self) -> List[str]:
-        """Split the format attribute into tokens.
-
-        For example, the format attribute "valid-url; is-reachable" will
-        be split into ["valid-url", "is-reachable"]. The semicolon is
-        used as a delimiter, but not if it is inside curly braces,
-        because the format string can contain Python expressions that
-        contain semicolons.
-        """
-        if self.format is None:
-            return []
-        pattern = re.compile(r";(?![^{}]*})")
-        tokens = re.split(pattern, self.format)
-        tokens = list(filter(None, tokens))
-        return tokens
+        validator_args = cls.parse(format_str)
+
+        on_fail_handlers = {}
+        for key, value in element.attrib.items():
+            key = cast_xml_to_string(key)
+            if key.startswith("on-fail-"):
+                on_fail_handler_name = key[len("on-fail-") :]
+                on_fail_handler = value
+                on_fail_handlers[on_fail_handler_name] = on_fail_handler
+
+        validators, unregistered_validators = cls.get_validators(
+            validator_args=validator_args,
+            tag=tag,
+            on_fail_handlers=on_fail_handlers,
+            strict=strict,
+        )
 
-    @classmethod
-    def parse_token(cls, token: str) -> Tuple[str, List[Any]]:
+        return cls(
+            format=format_str,
+            on_fail_handlers=on_fail_handlers,
+            validator_args=validator_args,
+            validators=validators,
+            unregistered_validators=unregistered_validators,
+        )
+
+    @staticmethod
+    def parse_token(token: str) -> Tuple[str, List[Any]]:
         """Parse a single token in the format attribute, and return the
         validator name and the list of arguments.
 
@@ -151,48 +179,35 @@ def parse_token(cls, token: str) -> Tuple[str, List[Any]]:
 
         return validator.strip(), args
 
-    def parse(self) -> Dict:
+    @staticmethod
+    def parse(format_string: str) -> Dict[str, List[Any]]:
         """Parse the format attribute into a dictionary of validators.
 
         Returns:
             A dictionary of validators, where the key is the validator name, and
             the value is a list of arguments.
         """
-        if self.format is None:
-            return {}
-
         # Split the format attribute into tokens: each is a validator.
         # Then, parse each token into a validator name and a list of parameters.
+        pattern = re.compile(r";(?![^{}]*})")
+        tokens = re.split(pattern, format_string)
+        tokens = list(filter(None, tokens))
+
         validators = {}
-        for token in self.tokens:
+        for token in tokens:
             # Parse the token into a validator name and a list of parameters.
-            validator_name, args = self.parse_token(token)
+            validator_name, args = FormatAttr.parse_token(token)
             validators[validator_name] = args
 
         return validators
 
-    @property
-    def validators(self) -> List[Validator]:
-        """Get the list of validators from the format attribute.
-
-        Only the validators that are registered for this element will be
-        returned.
-        """
-        try:
-            return getattr(self, "_validators")
-        except AttributeError:
-            raise AttributeError("Must call `get_validators` first.")
-
-    @property
-    def unregistered_validators(self) -> List[str]:
-        """Get the list of validators from the format attribute that are not
-        registered for this element."""
-        try:
-            return getattr(self, "_unregistered_validators")
-        except AttributeError:
-            raise AttributeError("Must call `get_validators` first.")
-
-    def get_validators(self, strict: bool = False) -> List[Validator]:
+    @staticmethod
+    def get_validators(
+        validator_args: Dict[str, List[Any]],
+        tag: str,
+        on_fail_handlers: Dict[str, str],
+        strict: bool = False,
+    ) -> Tuple[List[Validator], List[str]]:
         """Get the list of validators from the format attribute. Only the
         validators that are registered for this element will be returned.
 
@@ -208,26 +223,25 @@ def get_validators(self, strict: bool = False) -> List[Validator]:
         Returns:
             A list of validators.
         """
-        from guardrails.validators import types_to_validators, validators_registry
+        from guardrails.validator_base import types_to_validators, validators_registry
 
         _validators = []
         _unregistered_validators = []
-        parsed = self.parse().items()
-        for validator_name, args in parsed:
+        for validator_name, args in validator_args.items():
             # Check if the validator is registered for this element.
             # The validators in `format` that are not registered for this element
             # will be ignored (with an error or warning, depending on the value of
             # `strict`), and the registered validators will be returned.
-            if validator_name not in types_to_validators[self.element.tag]:
+            if validator_name not in types_to_validators[tag]:
                 if strict:
                     raise ValueError(
                         f"Validator {validator_name} is not valid for"
-                        f" element {self.element.tag}."
+                        f" element {tag}."
                     )
                 else:
                     warnings.warn(
                         f"Validator {validator_name} is not valid for"
-                        f" element {self.element.tag}."
+                        f" element {tag}."
                     )
                     _unregistered_validators.append(validator_name)
                 continue
@@ -235,20 +249,15 @@ def get_validators(self, strict: bool = False) -> List[Validator]:
             validator = validators_registry[validator_name]
 
             # See if the formatter has an associated on_fail method.
-            on_fail = None
-            on_fail_attr_name = f"on-fail-{validator_name}"
-            if on_fail_attr_name in self.element.attrib:
-                on_fail = self.element.attrib[on_fail_attr_name]
-                # TODO(shreya): Load the on_fail method.
-                # This method should be loaded from an optional script given at the
-                # beginning of a rail file.
+            on_fail = on_fail_handlers.get(validator_name, None)
+            # TODO(shreya): Load the on_fail method.
+            # This method should be loaded from an optional script given at the
+            # beginning of a rail file.
 
             # Create the validator.
             _validators.append(validator(*args, on_fail=on_fail))
 
-        self._validators = _validators
-        self._unregistered_validators = _unregistered_validators
-        return _validators
+        return _validators, _unregistered_validators
 
     def to_prompt(self, with_keywords: bool = True) -> str:
         """Convert the format string to another string representation for use
@@ -275,77 +284,47 @@ def to_prompt(self, with_keywords: bool = True) -> str:
 class Schema:
     """Schema class that holds a _schema attribute."""
 
+    reask_prompt_vars: Set[str]
+
     def __init__(
         self,
-        root: Optional[ET._Element] = None,
-        schema: Optional[Dict[str, DataType]] = None,
+        schema: DataType,
         reask_prompt_template: Optional[str] = None,
         reask_instructions_template: Optional[str] = None,
     ) -> None:
-        # Setup schema
-        if schema is None:
-            schema = {}
-        self._schema = SimpleNamespace(**schema)
-
-        # Setup root
-        self.root = root
-        if root is not None:
-            self.setup_schema(root)
+        self.root_datatype = schema
 
         # Setup reask templates
         self.check_valid_reask_prompt(reask_prompt_template)
-        self._reask_prompt_template = reask_prompt_template
         if reask_prompt_template is not None:
-            reask_prompt_template = Prompt(reask_prompt_template)
-        self.reask_instructions_template = reask_instructions_template
+            self._reask_prompt_template = Prompt(reask_prompt_template)
+        else:
+            self._reask_prompt_template = None
         if reask_instructions_template is not None:
-            reask_instructions_template = Prompt(reask_instructions_template)
-
-    def __repr__(self) -> str:
-        return f"{self.__class__.__name__}({pprint.pformat(vars(self._schema))})"
-
-    def __getitem__(self, key: str) -> DataType:
-        return getattr(self._schema, key)
-
-    def __setitem__(self, key: str, value: DataType) -> None:
-        setattr(self._schema, key, value)
-
-    def __getattr__(self, key: str) -> DataType:
-        return getattr(self._schema, key)
-
-    def __contains__(self, key: str) -> bool:
-        return hasattr(self._schema, key)
-
-    def __getstate__(self) -> Dict[str, Any]:
-        return {"_schema": self._schema, "root": self.root}
-
-    def __setstate__(self, state: Dict[str, Any]) -> None:
-        self._schema = state["_schema"]
-        self.root = state["root"]
-
-    def items(self) -> Dict[str, DataType]:
-        return vars(self._schema).items()
+            self._reask_instructions_template = Instructions(
+                reask_instructions_template
+            )
+        else:
+            self._reask_instructions_template = None
 
-    def to_dict(self) -> Dict[str, Any]:
-        """Convert the schema to a dictionary."""
-        return vars(self._schema)
+    @classmethod
+    def from_element(
+        cls,
+        root: ET._Element,
+        reask_prompt_template: Optional[str] = None,
+        reask_instructions_template: Optional[str] = None,
+    ) -> Self:
+        """Create a schema from an XML element."""
+        raise NotImplementedError
 
-    @property
-    def parsed_rail(self) -> Optional[ET._Element]:
-        return self.root
+    def __repr__(self) -> str:
+        # FIXME make sure this is pretty
+        return f"{self.__class__.__name__}({pprint.pformat(self.root_datatype)})"
 
     @property
     def reask_prompt_template(self) -> Optional[Prompt]:
         return self._reask_prompt_template
 
-    def setup_schema(self, root: ET._Element) -> None:
-        """Parse the schema specification.
-
-        Args:
-            root: The root element of the schema specification.
-        """
-        raise NotImplementedError
-
     def validate(self, guard_logs: GuardLogs, data: Any, metadata: Dict) -> Any:
         """Validate a dictionary of data against the schema.
 
@@ -444,13 +423,26 @@ def check_valid_reask_prompt(self, reask_prompt: Optional[str]) -> None:
             return
 
         # Check that the reask prompt has the correct variables
-        variables = [t[1] for t in Formatter().parse(reask_prompt) if t[1] is not None]
+        variables = get_template_variables(reask_prompt)
         assert set(variables) == self.reask_prompt_vars
 
 
 class JsonSchema(Schema):
     reask_prompt_vars = {"previous_response", "output_schema"}
 
+    def __init__(
+        self,
+        schema: Object,
+        reask_prompt_template: Optional[str] = None,
+        reask_instructions_template: Optional[str] = None,
+    ) -> None:
+        super().__init__(
+            schema,
+            reask_prompt_template=reask_prompt_template,
+            reask_instructions_template=reask_instructions_template,
+        )
+        self.root_datatype = schema
+
     def get_reask_setup(
         self,
         reasks: List[FieldReAsk],
@@ -458,7 +450,7 @@ def get_reask_setup(
         use_full_schema: bool,
         prompt_params: Optional[Dict[str, Any]] = None,
     ) -> Tuple["Schema", Prompt, Instructions]:
-        parsed_rail = deepcopy(self.root)
+        root = deepcopy(self.root_datatype)
 
         is_skeleton_reask = not any(isinstance(reask, FieldReAsk) for reask in reasks)
         is_nonparseable_reask = any(
@@ -474,7 +466,8 @@ def get_reask_setup(
                     constants["high_level_json_parsing_reask_prompt"]
                     + constants["json_suffix_without_examples"]
                 )
-            reask_value = original_response
+            np_reask: NonParseableReAsk = original_response
+            reask_value = np_reask.incorrect_value
         elif is_skeleton_reask:
             pruned_tree_schema = self
 
@@ -494,11 +487,9 @@ def get_reask_setup(
                 pruned_tree_schema = self
             else:
                 reask_value = prune_obj_for_reasking(original_response)
-                # Get the elements that are to be reasked
-                reask_elements = get_reasks_by_element(reasks, parsed_rail)
 
                 # Get the pruned tree so that it only contains ReAsk objects
-                pruned_tree = get_pruned_tree(parsed_rail, list(reask_elements.keys()))
+                pruned_tree = get_pruned_tree(root, reasks)
                 pruned_tree_schema = type(self)(pruned_tree)
 
             reask_prompt_template = self.reask_prompt_template
@@ -529,28 +520,35 @@ def reask_decoder(obj):
             **(prompt_params or {}),
         )
 
-        instructions = self.reask_instructions_template
+        instructions = self._reask_instructions_template
         if instructions is None:
             instructions = Instructions(constants["high_level_json_instructions"])
         instructions = instructions.format(**(prompt_params or {}))
 
         return pruned_tree_schema, prompt, instructions
 
-    def setup_schema(self, root: ET._Element) -> None:
-        from guardrails.datatypes import registry as types_registry
-
+    @classmethod
+    def from_element(
+        cls,
+        root: ET._Element,
+        reask_prompt_template: Optional[str] = None,
+        reask_instructions_template: Optional[str] = None,
+    ) -> Self:
         strict = False
         if "strict" in root.attrib and root.attrib["strict"] == "true":
             strict = True
 
-        for child in root:
-            if isinstance(child, ET._Comment):
-                continue
-            child_name = child.attrib["name"]
-            child_data = types_registry[child.tag].from_xml(child, strict=strict)
-            self[child_name] = child_data
+        schema = Object.from_xml(root, strict=strict)
 
-    def parse(self, output: str) -> Tuple[Dict, Optional[Exception]]:
+        return cls(
+            schema,
+            reask_prompt_template=reask_prompt_template,
+            reask_instructions_template=reask_instructions_template,
+        )
+
+    def parse(
+        self, output: str
+    ) -> Tuple[Union[Optional[Dict], NonParseableReAsk], Optional[Exception]]:
         # Try to get json code block from output.
         # Return error and reask if it is not parseable.
         parsed_output, error = extract_json_from_ouput(output)
@@ -591,7 +589,7 @@ def validate(
         validated_response = deepcopy(data)
 
         if not verify_schema_against_json(
-            self.root,
+            self.root_datatype,
             validated_response,
             prune_extra_keys=True,
             coerce_types=True,
@@ -606,33 +604,12 @@ def validate(
                 ],
             )
 
-        validation = FieldValidation(
+        validation = self.root_datatype.collect_validation(
             key="",
             value=validated_response,
-            validators=[],
-            children=[],
+            schema=validated_response,
         )
 
-        for field, value in validated_response.items():
-            if field not in self:
-                # This is an extra field that is not in the schema.
-                # We remove it from the validated response.
-                logger.debug(f"Field {field} not in schema.")
-                continue
-
-            logger.debug(f"Validating field {field} with value {value}.")
-
-            field_validation = self[field].collect_validation(
-                key=field,
-                value=value,
-                schema=validated_response,
-            )
-            validation.children.append(field_validation)
-
-            logger.debug(
-                f"Validated field {field} with value {validated_response[field]}."
-            )
-
         validation_logs = FieldValidationLogs()
         guard_logs.field_validation_logs = validation_logs
 
@@ -677,7 +654,7 @@ async def async_validate(
         validated_response = deepcopy(data)
 
         if not verify_schema_against_json(
-            self.root,
+            self.root_datatype,
             validated_response,
             prune_extra_keys=True,
             coerce_types=True,
@@ -692,33 +669,13 @@ async def async_validate(
                 ],
             )
 
-        validation = FieldValidation(
+        # FIXME make the top-level validation key-invariant
+        validation = self.root_datatype.collect_validation(
             key="",
             value=validated_response,
-            validators=[],
-            children=[],
+            schema=validated_response,
         )
 
-        for field, value in validated_response.items():
-            if field not in self:
-                # This is an extra field that is not in the schema.
-                # We remove it from the validated response.
-                logger.debug(f"Field {field} not in schema.")
-                continue
-
-            logger.debug(f"Validating field {field} with value {value}.")
-
-            field_validation = self[field].collect_validation(
-                key=field,
-                value=value,
-                schema=validated_response,
-            )
-            validation.children.append(field_validation)
-
-            logger.debug(
-                f"Validated field {field} with value {validated_response[field]}."
-            )
-
         validation_logs = FieldValidationLogs()
         guard_logs.field_validation_logs = validation_logs
 
@@ -779,33 +736,38 @@ class StringSchema(Schema):
 
     def __init__(
         self,
-        root: ET._Element,
+        schema: String,
         reask_prompt_template: Optional[str] = None,
         reask_instructions_template: Optional[str] = None,
     ) -> None:
-        self.string_key = "string"
-        super().__init__(root)
-
-        # Setup reask templates
-        self._reask_prompt_template = reask_prompt_template
-        if self._reask_prompt_template is not None:
-            self._reask_prompt_template = Prompt(reask_prompt_template)
-        self.reask_instructions_template = reask_instructions_template
-        if self.reask_instructions_template is not None:
-            self.reask_instructions_template = Prompt(self.reask_instructions_template)
+        super().__init__(
+            schema,
+            reask_prompt_template=reask_prompt_template,
+            reask_instructions_template=reask_instructions_template,
+        )
+        self.root_datatype = schema
 
-    def setup_schema(self, root: ET._Element) -> None:
+    @classmethod
+    def from_element(
+        cls,
+        root: ET._Element,
+        reask_prompt_template: Optional[str] = None,
+        reask_instructions_template: Optional[str] = None,
+    ) -> Self:
         if len(root) != 0:
             raise ValueError("String output schemas must not have children.")
 
-        if "name" in root.attrib:
-            self.string_key = root.attrib["name"]
-        else:
-            self.string_key = root.attrib["name"] = "string"
+        strict = False
+        if "strict" in root.attrib and root.attrib["strict"] == "true":
+            strict = True
 
-        # make root tag into a string tag
-        root_string = ET.Element("string", root.attrib)
-        self[self.string_key] = String.from_xml(root_string)
+        schema = String.from_xml(root, strict=strict)
+
+        return cls(
+            schema=schema,
+            reask_prompt_template=reask_prompt_template,
+            reask_instructions_template=reask_instructions_template,
+        )
 
     def get_reask_setup(
         self,
@@ -838,7 +800,7 @@ def get_reask_setup(
             **(prompt_params or {}),
         )
 
-        instructions = self.reask_instructions_template
+        instructions = self._reask_instructions_template
         if instructions is None:
             instructions = Instructions("You are a helpful assistant.")
         instructions = instructions.format(**(prompt_params or {}))
@@ -871,11 +833,14 @@ def validate(
         validation_logs = FieldValidationLogs()
         guard_logs.field_validation_logs = validation_logs
 
-        validation = self[self.string_key].collect_validation(
-            key=self.string_key,
+        # FIXME instead of writing the validation infrastructure for dicts (JSON),
+        #  make it more structure-invariant
+        dummy_key = "string"
+        validation = self.root_datatype.collect_validation(
+            key=dummy_key,
             value=data,
             schema={
-                self.string_key: data,
+                dummy_key: data,
             },
         )
 
@@ -886,7 +851,7 @@ def validate(
             validation_logs=validation_logs,
         )
 
-        validated_response = {self.string_key: validated_response}
+        validated_response = {dummy_key: validated_response}
 
         if check_refrain_in_dict(validated_response):
             # If the data contains a `Refain` value, we return an empty
@@ -897,8 +862,8 @@ def validate(
         # Remove all keys that have `Filter` values.
         validated_response = filter_in_dict(validated_response)
 
-        if self.string_key in validated_response:
-            return validated_response[self.string_key]
+        if dummy_key in validated_response:
+            return validated_response[dummy_key]
         return None
 
     async def async_validate(
@@ -924,11 +889,12 @@ async def async_validate(
         validation_logs = FieldValidationLogs()
         guard_logs.field_validation_logs = validation_logs
 
-        validation = self[self.string_key].collect_validation(
-            key=self.string_key,
+        dummy_key = "string"
+        validation = self.root_datatype.collect_validation(
+            key=dummy_key,
             value=data,
             schema={
-                self.string_key: data,
+                dummy_key: data,
             },
         )
 
@@ -939,7 +905,7 @@ async def async_validate(
             validation_logs=validation_logs,
         )
 
-        validated_response = {self.string_key: validated_response}
+        validated_response = {dummy_key: validated_response}
 
         if check_refrain_in_dict(validated_response):
             # If the data contains a `Refain` value, we return an empty
@@ -950,8 +916,8 @@ async def async_validate(
         # Remove all keys that have `Filter` values.
         validated_response = filter_in_dict(validated_response)
 
-        if self.string_key in validated_response:
-            return validated_response[self.string_key]
+        if dummy_key in validated_response:
+            return validated_response[dummy_key]
         return None
 
     def introspect(self, data: Any) -> List[FieldReAsk]:
@@ -980,12 +946,12 @@ def preprocess_prompt(
         return instructions, prompt
 
     def transpile(self, method: str = "default") -> str:
-        obj = self[self.string_key]
+        obj = self.root_datatype
         schema = ""
-        if "description" in obj.element.attrib:
+        if obj.description is not None:
             schema += (
                 "Here's a description of what I want you to generate: "
-                f"{obj.element.attrib['description']}"
+                f"{obj.description}"
             )
         if not obj.format_attr.empty:
             schema += (
@@ -1007,63 +973,42 @@ class Schema2Prompt:
     """
 
     @staticmethod
-    def remove_on_fail_attributes(element: ET._Element) -> None:
-        """Recursively remove all attributes that start with 'on-fail-'."""
-        for attr in list(element.attrib):
-            if attr.startswith("on-fail-"):
-                del element.attrib[attr]
-
-        for child in element:
-            Schema2Prompt.remove_on_fail_attributes(child)
-
-    @staticmethod
-    def remove_comments(element: ET._Element) -> None:
-        """Recursively remove all comments."""
-        for child in element:
-            if isinstance(child, ET._Comment):
-                element.remove(child)
-            else:
-                Schema2Prompt.remove_comments(child)
+    def datatypes_to_xml(
+        dt: DataType,
+        root: Optional[ET._Element] = None,
+        override_tag_name: Optional[str] = None,
+    ) -> ET._Element:
+        """Recursively convert the datatypes to XML elements."""
+        if root is None:
+            tagname = override_tag_name or dt.tag
+            el = ET.Element(tagname)
+        else:
+            el = ET.SubElement(root, dt.tag)
 
-    @staticmethod
-    def validator_to_prompt(root: ET.Element, schema_dict: Dict[str, DataType]) -> None:
-        """Recursively remove all validator arguments in the `format`
-        attribute."""
-
-        def _inner(dt: DataType, el: ET._Element):
-            if "format" in el.attrib:
-                format = dt.format_attr.to_prompt()
-                if len(format):
-                    el.attrib["format"] = format
-                else:
-                    del el.attrib["format"]
+        if dt.name:
+            el.attrib["name"] = dt.name
 
-            for _, dt_child, el_child in dt.iter(el):
-                _inner(dt_child, el_child)
+        if dt.description:
+            el.attrib["description"] = dt.description
 
-        for el_child in root:
-            dt_child = schema_dict[el_child.attrib["name"]]
-            _inner(dt_child, el_child)
+        if dt.format_attr:
+            format_prompt = dt.format_attr.to_prompt()
+            if format_prompt:
+                el.attrib["format"] = format_prompt
 
-    @staticmethod
-    def pydantic_to_object(root: ET.Element, schema_dict: Dict[str, DataType]) -> None:
-        """Recursively replace all pydantic elements with object elements."""
-        from guardrails.datatypes import Pydantic
+        if dt.optional:
+            el.attrib["required"] = "false"
 
-        def _inner(dt: DataType, el: ET._Element):
-            if isinstance(dt, Pydantic):
-                new_el = dt.to_object_element()
-                el.getparent().replace(el, new_el)
+        if isinstance(dt, Choice):
+            el.attrib["discriminator"] = dt.discriminator_key
 
-            for _, dt_child, el_child in dt.iter(el):
-                _inner(dt_child, el_child)
+        for child in dt._children.values():
+            Schema2Prompt.datatypes_to_xml(child, el)
 
-        for el_child in root:
-            dt_child = schema_dict[el_child.attrib["name"]]
-            _inner(dt_child, el_child)
+        return el
 
     @classmethod
-    def default(cls, schema: Schema) -> str:
+    def default(cls, schema: JsonSchema) -> str:
         """Default transpiler.
 
         Converts the XML schema to a string directly after removing:
@@ -1077,17 +1022,10 @@ def default(cls, schema: Schema) -> str:
             The prompt.
         """
         # Construct another XML tree from the schema.
-        root = deepcopy(schema.root)
-        schema_dict = schema.to_dict()
+        schema_object = schema.root_datatype
 
-        # Remove comments.
-        cls.remove_comments(root)
-        # Remove action attributes.
-        cls.remove_on_fail_attributes(root)
         # Remove validators with arguments.
-        cls.validator_to_prompt(root, schema_dict)
-        # Replace pydantic elements with object elements.
-        cls.pydantic_to_object(root, schema_dict)
+        root = cls.datatypes_to_xml(schema_object, override_tag_name="output")
 
         # Return the XML as a string that is
         ET.indent(root, space="    ")
diff --git a/guardrails/utils/casting_utils.py b/guardrails/utils/casting_utils.py
index ef4aa898a..7e4ee2d2f 100644
--- a/guardrails/utils/casting_utils.py
+++ b/guardrails/utils/casting_utils.py
@@ -1,7 +1,7 @@
-from typing import Any
+from typing import Any, Optional
 
 
-def to_int(v: Any) -> int:
+def to_int(v: Any) -> Optional[int]:
     try:
         int_value = int(v)
         return int_value
@@ -9,7 +9,7 @@ def to_int(v: Any) -> int:
         return None
 
 
-def to_float(v: Any) -> float:
+def to_float(v: Any) -> Optional[float]:
     try:
         float_value = float(v)
         return float_value
@@ -17,7 +17,7 @@ def to_float(v: Any) -> float:
         return None
 
 
-def to_string(v: Any) -> str:
+def to_string(v: Any) -> Optional[str]:
     try:
         str_value = str(v)
         return str_value
diff --git a/guardrails/utils/docs_utils.py b/guardrails/utils/docs_utils.py
index c3775c754..55238c59f 100644
--- a/guardrails/utils/docs_utils.py
+++ b/guardrails/utils/docs_utils.py
@@ -8,15 +8,15 @@
     tiktoken = None
 
 try:
-    import nltk
+    import nltk  # type: ignore
 except ImportError:
     nltk = None
 
-try:
-    if nltk is not None:
+if nltk is not None:
+    try:
         nltk.data.find("tokenizers/punkt")
-except LookupError:
-    nltk.download("punkt")
+    except LookupError:
+        nltk.download("punkt")
 
 
 class TextSplitter:
@@ -52,7 +52,7 @@ def split(
             chunks.append(self.tokenizer.decode(tokens[i : i + tokens_per_chunk]))
         return chunks
 
-    def prompt_template_token_length(self, prompt_template: Prompt) -> str:
+    def prompt_template_token_length(self, prompt_template: Prompt) -> int:
         """Exclude the tokens used in the prompt template from the text."""
         # TODO(shreya) Make sure that prompt_template.source is correct, and
         # doesn't contain extra metadata.
@@ -60,7 +60,7 @@ def prompt_template_token_length(self, prompt_template: Prompt) -> str:
         prompt_vars = prompt_template.get_prompt_variables()
 
         tokens = self.tokenizer.encode(
-            prompt_template.format(**{var: "" for var in prompt_vars})
+            str(prompt_template.format(**{var: "" for var in prompt_vars}))
         )
         return len(tokens)
 
@@ -71,7 +71,7 @@ def __call__(self, *args: t.Any, **kwds: t.Any) -> t.Any:
 def sentence_split(text: str) -> t.List[str]:
     """Split the text into sentences."""
     try:
-        from nltk import sent_tokenize
+        from nltk import sent_tokenize  # type: ignore
     except ImportError:
         raise ImportError(
             "nltk is required for sentence splitting. Please install it using "
@@ -79,7 +79,7 @@ def sentence_split(text: str) -> t.List[str]:
         )
 
     # Download the nltk punkt tokenizer if it's not already downloaded.
-    import nltk
+    import nltk  # type: ignore
 
     try:
         nltk.data.find("tokenizers/punkt")
@@ -120,25 +120,31 @@ def get_chunks_from_text(
             chunk_strategy is "sentences", this is the number of sentences to overlap
             between chunks.
     """
-    if chunk_strategy in ["sentence", "word"] and nltk is None:
-        raise ImportError(
-            "nltk is required for sentence splitting. Please install it using "
-            "`pip install nltk`"
-        )
-    if chunk_strategy == "token" and tiktoken is None:
-        raise ImportError(
-            "tiktoken is required for token splitting. Please install it using "
-            "`pip install tiktoken`"
-        )
+
+    nltk_error = (
+        "nltk is required for sentence splitting. Please install it using "
+        "`pip install nltk`"
+    )
+    tiktoken_error = (
+        "tiktoken is required for token splitting. Please install it using "
+        "`pip install tiktoken`"
+    )
 
     if chunk_strategy == "sentence":
+        if nltk is None:
+            raise ImportError(nltk_error)
         atomic_chunks = nltk.sent_tokenize(text)
     elif chunk_strategy == "word":
+        if nltk is None:
+            raise ImportError(nltk_error)
         atomic_chunks = nltk.word_tokenize(text)
     elif chunk_strategy == "char":
         atomic_chunks = list(text)
     elif chunk_strategy == "token":
-        atomic_chunks = tiktoken(text)
+        if tiktoken is None:
+            raise ImportError(tiktoken_error)
+        # FIXME is this the correct way to use tiktoken?
+        atomic_chunks = tiktoken(text)  # type: ignore
     else:
         raise ValueError(
             "chunk_strategy must be 'sentence', 'word', 'char', or 'token'."
diff --git a/guardrails/utils/json_utils.py b/guardrails/utils/json_utils.py
index 0ba0dadcd..f3f0db33c 100644
--- a/guardrails/utils/json_utils.py
+++ b/guardrails/utils/json_utils.py
@@ -4,8 +4,25 @@
 from dataclasses import dataclass
 from typing import Any, Dict, Optional, Tuple, Type, Union
 
-import lxml.etree as ET
-
+from guardrails.datatypes import (
+    URL,
+    Boolean,
+    Case,
+    Choice,
+    DataType,
+    Date,
+    Email,
+    Float,
+    Integer,
+)
+from guardrails.datatypes import List as ListDataType
+from guardrails.datatypes import (
+    Object,
+    PythonCode,
+    String,
+    Time,
+    deprecated_string_types,
+)
 from guardrails.utils.parsing_utils import get_code_block, has_code_block
 
 logger = logging.getLogger(__name__)
@@ -26,37 +43,33 @@ def verify(
         return None
 
 
-# TODO - deprecate these altogether
-deprecated_string_types = {"sql", "email", "url", "pythoncode"}
+type_map: Dict[Type[DataType], Type] = {
+    String: str,
+    Integer: int,
+    Float: float,
+    Boolean: bool,
+    Object: dict,
+    ListDataType: list,
+    Date: str,
+    Time: str,
+}
+
+ignore_types = [
+    Email,  # email and url should become string validators
+    URL,
+    PythonCode,
+]
 
 
 @dataclass
 class ValuePlaceholder(Placeholder):
-    type_map = {
-        "string": str,
-        "integer": int,
-        "float": float,
-        "bool": bool,
-        "object": dict,
-        "list": list,
-        "date": str,
-        "time": str,
-        "email": str,  # email and url should become string validators
-        "url": str,
-        "pythoncode": str,
-        "sql": str,
-    }
-    ignore_types = [
-        "pydantic",
-    ]
-
-    type_string: str
+    datatype_type: Type[DataType]
 
     @property
     def type_object(self):
-        if self.type_string in self.ignore_types:
+        if self.datatype_type in ignore_types:
             return Any
-        return self.type_map[self.type_string]
+        return type_map[self.datatype_type]
 
     class VerificationFailed:
         # Sentinel value
@@ -251,76 +264,74 @@ def verify(
         )
 
 
-def generate_type_skeleton_from_schema(schema: ET._Element) -> Placeholder:
+def generate_type_skeleton_from_schema(schema: Object) -> Placeholder:
     """Generate a JSON skeleton from an XML schema."""
 
-    def _recurse_schema(schema):
-        is_optional = schema.attrib.get("required", "true") == "false"
-        if schema.tag == "object":
+    def _recurse_schema(schema: DataType):
+        if isinstance(schema, Object):
             return DictPlaceholder(
                 children={
-                    child.attrib["name"]: _recurse_schema(child) for child in schema
+                    name_: _recurse_schema(child_)
+                    for name_, child_ in vars(schema.children).items()
                 },
-                optional=is_optional,
+                optional=schema.optional,
             )
-        elif schema.tag == "list":
-            if len(schema) == 0:
-                child = None
-            elif len(schema) == 1:
-                child = _recurse_schema(schema[0])
+        if isinstance(schema, ListDataType):
+            child_len = len(vars(schema.children).values())
+            if not child_len:
+                child_ = None
+            elif child_len == 1:
+                child_ = _recurse_schema(list(vars(schema.children).values())[0])
             else:
                 raise ValueError("List must have exactly zero or one child")
             return ListPlaceholder(
-                child=child,
-                optional=is_optional,
+                child=child_,
+                optional=schema.optional,
             )
-        elif schema.tag == "choice":
+        if isinstance(schema, Choice):
             return ChoicePlaceholder(
                 cases={
-                    case.attrib["name"]: DictPlaceholder(
+                    name_: DictPlaceholder(
                         children={
-                            child.attrib["name"]: _recurse_schema(child)
-                            for child in case
+                            name__: _recurse_schema(child__)
+                            for name__, child__ in vars(case.children).items()
                         },
-                        optional=is_optional,
+                        optional=schema.optional,
                     )
-                    for case in schema
-                    if case.tag == "case"
+                    for name_, case in vars(schema.children).items()
+                    if isinstance(case, Case)
                 },
-                optional=is_optional,
-                discriminator=schema.attrib["discriminator"],
+                optional=schema.optional,
+                discriminator=schema.discriminator_key,
             )
         else:
-            type_string = schema.tag
+            datatype_type = type(schema)
             if schema.tag in deprecated_string_types:
+                datatype_type = String
                 warnings.warn(
                     f"""The '{schema.tag}' type is deprecated. Use the \
 string type instead. Support for this type will \
 be dropped in version 0.3.0 and beyond.""",
                     DeprecationWarning,
                 )
-                type_string = "string"
 
             return ValuePlaceholder(
-                type_string=type_string,
-                optional=is_optional,
+                datatype_type=datatype_type,
+                optional=schema.optional,
             )
 
-    return DictPlaceholder(
-        children={child.attrib["name"]: _recurse_schema(child) for child in schema},
-        optional=False,
-    )
+    return _recurse_schema(schema)
 
 
 def verify_schema_against_json(
-    xml_schema: ET._Element,
+    schema: Object,
     generated_json: Dict[str, Any],
     prune_extra_keys: bool = False,
     coerce_types: bool = False,
 ):
     """Verify that a JSON schema is valid for a given XML."""
 
-    type_skeleton = generate_type_skeleton_from_schema(xml_schema)
+    type_skeleton = generate_type_skeleton_from_schema(schema)
     return type_skeleton.verify(
         generated_json,
         prune_extra_keys=prune_extra_keys,
@@ -328,15 +339,15 @@ def verify_schema_against_json(
     )
 
 
-def extract_json_from_ouput(output: str) -> Tuple[Dict, Optional[Exception]]:
+def extract_json_from_ouput(output: str) -> Tuple[Optional[Dict], Optional[Exception]]:
     # Find and extract json from code blocks
     extracted_code_block = output
     has_json_block, json_start, json_end = has_code_block(output, "json")
-    if has_json_block:
+    if has_json_block and json_start is not None and json_end is not None:
         extracted_code_block = get_code_block(output, json_start, json_end, "json")
     else:
         has_block, block_start, block_end = has_code_block(output)
-        if has_block:
+        if has_block and block_start is not None and block_end is not None:
             extracted_code_block = get_code_block(output, block_start, block_end)
 
     # Treat the output as a JSON string, and load it into a dict.
diff --git a/guardrails/utils/logs_utils.py b/guardrails/utils/logs_utils.py
index a96a35bae..34d42c10d 100644
--- a/guardrails/utils/logs_utils.py
+++ b/guardrails/utils/logs_utils.py
@@ -1,5 +1,5 @@
 from copy import deepcopy
-from typing import Any, Dict, List, Optional, Union
+from typing import Any, Dict, List, Optional, Sequence, Union
 
 from pydantic import BaseModel, Field, PrivateAttr
 from rich.console import Group
@@ -10,6 +10,7 @@
 
 from guardrails.prompt import Instructions, Prompt
 from guardrails.utils.reask_utils import (
+    FieldReAsk,
     ReAsk,
     SkeletonReAsk,
     gather_reasks,
@@ -42,7 +43,7 @@ class FieldValidationLogs(ArbitraryModel):
 class LLMResponse(ArbitraryModel):
     prompt_token_count: Optional[int] = None
     response_token_count: Optional[int] = None
-    output: Optional[str] = None
+    output: str
 
 
 class GuardLogs(ArbitraryModel):
@@ -50,9 +51,9 @@ class GuardLogs(ArbitraryModel):
     instructions: Optional[Instructions] = None
     llm_response: Optional[LLMResponse] = None
     msg_history: Optional[List[Dict[str, Prompt]]] = None
-    parsed_output: Optional[dict] = None
-    validated_output: Optional[dict] = None
-    reasks: Optional[List[ReAsk]] = None
+    parsed_output: Optional[Dict] = None
+    validated_output: Optional[Union[Dict, ReAsk]] = None
+    reasks: Optional[Sequence[ReAsk]] = None
 
     field_validation_logs: Optional[FieldValidationLogs] = None
 
@@ -85,7 +86,7 @@ def output(self) -> Optional[str]:
     def rich_group(self) -> Group:
         def create_msg_history_table(
             msg_history: Optional[List[Dict[str, Prompt]]]
-        ) -> Table:
+        ) -> Union[str, Table]:
             if msg_history is None:
                 return "No message history."
             table = Table(show_lines=True)
@@ -93,7 +94,7 @@ def create_msg_history_table(
             table.add_column("Content")
 
             for msg in msg_history:
-                table.add_row(msg["role"], msg["content"].source)
+                table.add_row(str(msg["role"]), msg["content"].source)
 
             return table
 
@@ -110,7 +111,7 @@ def create_msg_history_table(
                     self.instructions.source, title="Instructions", style="on #FFF0F2"
                 ),
                 Panel(table, title="Message History", style="on #E7DFEB"),
-                Panel(self.output, title="Raw LLM Output", style="on #F5F5DC"),
+                Panel(self.output or "", title="Raw LLM Output", style="on #F5F5DC"),
                 Panel(
                     pretty_repr(self.validated_output),
                     title="Validated Output",
@@ -125,7 +126,7 @@ def create_msg_history_table(
                     style="on #F0F8FF",
                 ),
                 Panel(table, title="Message History", style="on #E7DFEB"),
-                Panel(self.output, title="Raw LLM Output", style="on #F5F5DC"),
+                Panel(self.output or "", title="Raw LLM Output", style="on #F5F5DC"),
                 Panel(
                     pretty_repr(self.validated_output),
                     title="Validated Output",
@@ -153,34 +154,34 @@ def tree(self) -> Tree:
         return tree
 
     @property
-    def validated_output(self) -> dict:
+    def validated_output(self) -> Optional[Union[Dict, ReAsk]]:
         """Returns the latest validated output."""
         return self.history[-1].validated_output
 
     @property
-    def output(self) -> str:
+    def output(self) -> Optional[str]:
         """Returns the latest output."""
         return self.history[-1].output
 
     @property
-    def output_as_dict(self) -> dict:
+    def output_as_dict(self) -> Optional[Dict]:
         """Returns the latest output as a dict."""
         return self.history[-1].parsed_output
 
     @property
-    def failed_validations(self) -> List[ReAsk]:
+    def failed_validations(self) -> List[List[ReAsk]]:
         """Returns all failed validations."""
         return [log.failed_validations for log in self.history]
 
 
 class GuardState(ArbitraryModel):
-    all_histories: List[GuardHistory]
+    all_histories: List[GuardHistory] = Field(default_factory=list)
 
     def push(self, guard_history: GuardHistory) -> None:
         self.all_histories += [guard_history]
 
     @property
-    def most_recent_call(self) -> GuardHistory:
+    def most_recent_call(self) -> Optional[GuardHistory]:
         """Returns the most recent call."""
         if not len(self.all_histories):
             return None
@@ -210,7 +211,6 @@ def merge_reask_output(previous_response, reask_response) -> Dict:
     Returns:
         The merged output.
     """
-    from guardrails.validators import PydanticReAsk
 
     if isinstance(previous_response, ReAsk):
         return reask_response
@@ -223,18 +223,13 @@ def merge_reask_output(previous_response, reask_response) -> Dict:
     merged_json = deepcopy(previous_response)
 
     def update_reasked_elements(pruned_reask_json, reask_response_dict):
-        if isinstance(pruned_reask_json, PydanticReAsk):
-            corrected_value = reask_response_dict
-            # Get the path from any of the ReAsk objects in the PydanticReAsk object
-            # all of them have the same path.
-            path = [v.path for v in pruned_reask_json.values() if isinstance(v, ReAsk)][
-                0
-            ]
-            update_response_by_path(merged_json, path, corrected_value)
-
-        elif isinstance(pruned_reask_json, dict):
+        if isinstance(pruned_reask_json, dict):
             for key, value in pruned_reask_json.items():
-                if isinstance(value, ReAsk):
+                if isinstance(value, FieldReAsk):
+                    if value.path is None:
+                        raise RuntimeError(
+                            "FieldReAsk object must have a path attribute."
+                        )
                     corrected_value = reask_response_dict.get(key)
                     update_response_by_path(merged_json, value.path, corrected_value)
                 else:
@@ -243,7 +238,11 @@ def update_reasked_elements(pruned_reask_json, reask_response_dict):
                     )
         elif isinstance(pruned_reask_json, list):
             for i, item in enumerate(pruned_reask_json):
-                if isinstance(item, ReAsk):
+                if isinstance(item, FieldReAsk):
+                    if item.path is None:
+                        raise RuntimeError(
+                            "FieldReAsk object must have a path attribute."
+                        )
                     corrected_value = reask_response_dict[i]
                     update_response_by_path(merged_json, item.path, corrected_value)
                 else:
diff --git a/guardrails/utils/misc.py b/guardrails/utils/misc.py
index 910962e85..37638cf7a 100644
--- a/guardrails/utils/misc.py
+++ b/guardrails/utils/misc.py
@@ -2,7 +2,7 @@
 import random
 from typing import List
 
-from lxml.builder import E
+from lxml.etree import Element as E
 from rich.pretty import pretty_repr
 
 from guardrails import datatypes as dt
@@ -53,14 +53,14 @@ def generate_test_artifacts(
         with open(
             os.path.join(artifact_dir, f"compiled_prompt_{on_fail_type}{ext}.txt"), "w"
         ) as f:
-            f.write(compiled_prompt)
+            f.write(str(compiled_prompt or ""))
 
         # Save the llm output.
         llm_output = logs.output
         with open(
             os.path.join(artifact_dir, f"llm_output_{on_fail_type}{ext}.txt"), "w"
         ) as f:
-            f.write(llm_output)
+            f.write(llm_output or "")
 
         # Save the validated response.
         validated_output = logs.validated_output
@@ -97,7 +97,7 @@ def random_scalar_datatype():
                 dt.Time,
             ]
         )
-        return selected_datatype(None, None, None).rail_alias
+        return selected_datatype(None, None, None).rail_alias  # type: ignore
 
     def generate_schema(curr_depth):
         if curr_depth < depth:
diff --git a/guardrails/utils/parsing_utils.py b/guardrails/utils/parsing_utils.py
index 7f333b179..3309b7c74 100644
--- a/guardrails/utils/parsing_utils.py
+++ b/guardrails/utils/parsing_utils.py
@@ -1,9 +1,11 @@
-from typing import Optional
+import collections
+from string import Template
+from typing import List, Optional, Tuple
 
 
 def has_code_block(
     string_value: str, code_type: str = ""
-) -> (bool, Optional[int], Optional[int]):
+) -> Tuple[bool, Optional[int], Optional[int]]:
     """Checks if a string contains a code block denoted by leading and trailing
     tripple ticks (```) with an optional code type for the opening tag.
 
@@ -27,7 +29,7 @@ def has_code_block(
         return (
             (True, block_start_index, block_end_index)
             if block_end_index != -1
-            else (False)
+            else (False, None, None)
         )
     return (False, None, None)
 
@@ -59,3 +61,12 @@ def get_code_block(
     trimmed_output = contents.strip()
 
     return trimmed_output
+
+
+def get_template_variables(template: str) -> List[str]:
+    if hasattr(Template, "get_identifiers"):
+        return Template(template).get_identifiers()
+    else:
+        d = collections.defaultdict(str)
+        Template(template).safe_substitute(d)
+        return list(d.keys())
diff --git a/guardrails/utils/pydantic_utils.py b/guardrails/utils/pydantic_utils.py
index 09a213b72..c328ab935 100644
--- a/guardrails/utils/pydantic_utils.py
+++ b/guardrails/utils/pydantic_utils.py
@@ -16,10 +16,10 @@
     get_origin,
 )
 
+import lxml.etree as ET
 from griffe.dataclasses import Docstring
 from griffe.docstrings.parsers import Parser, parse
-from lxml.builder import E
-from lxml.etree import Element
+from lxml.etree import Element as E
 from pydantic import BaseModel, HttpUrl, validator
 from pydantic.fields import ModelField
 
@@ -35,7 +35,7 @@ def get_field_descriptions(model: "BaseModel") -> Dict[str, str]:
     griffe_docstrings_google_logger.disabled = True
     griffe_agents_nodes_logger.disabled = True
     try:
-        docstring = Docstring(model.__doc__, lineno=1)
+        docstring = Docstring(model.__doc__, lineno=1)  # type: ignore
     except AttributeError:
         return {}
     parsed = parse(docstring, Parser.google)
@@ -68,7 +68,7 @@ def is_pydantic_base_model(type_annotation: Any) -> bool:
         if issubclass(type_annotation, BaseModel):
             return True
     except TypeError:
-        False
+        pass
     return False
 
 
@@ -170,7 +170,9 @@ def type_annotation_to_string(type_annotation: Any) -> str:
         raise ValueError(f"Unsupported type: {type_annotation}")
 
 
-def add_validators_to_xml_element(field_info: ModelField, element: Element) -> Element:
+def add_validators_to_xml_element(
+    field_info: ModelField, element: ET._Element
+) -> ET._Element:
     """Extract validators from a pydantic ModelField and add to XML element.
 
     Args:
@@ -200,7 +202,7 @@ def add_validators_to_xml_element(field_info: ModelField, element: Element) -> E
 
 
 def attach_validators_to_element(
-    element: Element,
+    element: ET._Element,
     validators: Union[List[Validator], List[str]],
 ):
     format_prompt = []
@@ -255,10 +257,10 @@ def attach_validators_to_element(
 
 
 def create_xml_element_for_field(
-    field: Union[ModelField, Type, type],
+    field: Union[ModelField, Type],
     field_name: Optional[str] = None,
     exclude_subfields: Optional[typing.List[str]] = None,
-) -> Element:
+) -> ET._Element:
     """Create an XML element corresponding to a field.
 
     Args:
@@ -319,7 +321,7 @@ def create_xml_element_for_field(
             inner_type = get_args(type_annotation)
             if len(inner_type) == 0:
                 # If the list is empty, we cannot infer the type of the elements
-                pass
+                return element
 
             inner_type = inner_type[0]
             if is_pydantic_base_model(inner_type):
@@ -350,10 +352,10 @@ def create_xml_element_for_field(
 
 
 def create_xml_element_for_base_model(
-    model: BaseModel,
-    element: Optional[Element] = None,
+    model: Type[BaseModel],
+    element: Optional[ET._Element] = None,
     exclude_subfields: Optional[typing.List[str]] = None,
-) -> Element:
+) -> ET._Element:
     """Create an XML element for a Pydantic BaseModel.
 
     This function does the following:
@@ -372,7 +374,9 @@ def create_xml_element_for_base_model(
         exclude_subfields = []
 
     if element is None:
-        element = E("object")
+        element_ = E("object")
+    else:
+        element_ = element
 
     # Extract pydantic validators from the model and add them as guardrails validators
     model_fields = add_pydantic_validators_as_guardrails_validators(model)
@@ -382,9 +386,9 @@ def create_xml_element_for_base_model(
         if field_name in exclude_subfields:
             continue
         field_element = create_xml_element_for_field(field, field_name)
-        element.append(field_element)
+        element_.append(field_element)
 
-    return element
+    return element_
 
 
 def add_validator(
@@ -395,8 +399,8 @@ def add_validator(
     check_fields: bool = True,
     whole: Optional[bool] = None,
     allow_reuse: bool = True,
-    fn: Optional[Callable] = None,
-) -> Callable:
+    fn: Callable,
+) -> classmethod:
     return validator(
         *fields,
         pre=pre,
@@ -409,8 +413,8 @@ def add_validator(
 
 
 def convert_pydantic_validator_to_guardrails_validator(
-    model: BaseModel, fn: Callable
-) -> Validator:
+    model: Type[BaseModel], fn: Callable
+) -> Union[str, Validator]:
     """Convert a Pydantic validator to a Guardrails validator.
 
     Pydantic validators can be defined in three ways:
@@ -458,7 +462,7 @@ def convert_pydantic_validator_to_guardrails_validator(
 
 
 def add_pydantic_validators_as_guardrails_validators(
-    model: BaseModel,
+    model: Type[BaseModel],
 ) -> Dict[str, ModelField]:
     """Extract all validators for a pydantic BaseModel.
 
diff --git a/guardrails/utils/reask_utils.py b/guardrails/utils/reask_utils.py
index c5112d25b..368b21686 100644
--- a/guardrails/utils/reask_utils.py
+++ b/guardrails/utils/reask_utils.py
@@ -1,10 +1,10 @@
-from collections import defaultdict
-from typing import Any, Dict, List, Union
+from typing import Any, Dict, List, Optional, Union
 
 import pydantic
-from lxml import etree as ET
 
-from guardrails.validators import FailResult
+from guardrails.datatypes import List as ListType
+from guardrails.datatypes import Object as ObjectType
+from guardrails.validator_base import FailResult
 
 
 class ReAsk(pydantic.BaseModel):
@@ -13,7 +13,7 @@ class ReAsk(pydantic.BaseModel):
 
 
 class FieldReAsk(ReAsk):
-    path: List[Any] = None
+    path: Optional[List[Any]] = None
 
 
 class SkeletonReAsk(ReAsk):
@@ -24,7 +24,7 @@ class NonParseableReAsk(ReAsk):
     pass
 
 
-def gather_reasks(validated_output: Dict) -> List[FieldReAsk]:
+def gather_reasks(validated_output: Optional[Union[Dict, ReAsk]]) -> List[ReAsk]:
     """Traverse output and gather all ReAsk objects.
 
     Args:
@@ -34,18 +34,21 @@ def gather_reasks(validated_output: Dict) -> List[FieldReAsk]:
     Returns:
         A list of ReAsk objects found in the output.
     """
-    from guardrails.validators import PydanticReAsk
+    if validated_output is None:
+        return []
+    if isinstance(validated_output, ReAsk):
+        return [validated_output]
 
     reasks = []
 
-    def _gather_reasks_in_dict(output: Dict, path: List[str] = []) -> None:
-        is_pydantic = isinstance(output, PydanticReAsk)
+    def _gather_reasks_in_dict(
+        output: Dict, path: Optional[List[Union[str, int]]] = None
+    ) -> None:
+        if path is None:
+            path = []
         for field, value in output.items():
             if isinstance(value, FieldReAsk):
-                if is_pydantic:
-                    value.path = path
-                else:
-                    value.path = path + [field]
+                value.path = path + [field]
                 reasks.append(value)
 
             if isinstance(value, dict):
@@ -55,7 +58,11 @@ def _gather_reasks_in_dict(output: Dict, path: List[str] = []) -> None:
                 _gather_reasks_in_list(value, path + [field])
         return
 
-    def _gather_reasks_in_list(output: List, path: List[str] = []) -> None:
+    def _gather_reasks_in_list(
+        output: List, path: Optional[List[Union[str, int]]] = None
+    ) -> None:
+        if path is None:
+            path = []
         for idx, item in enumerate(output):
             if isinstance(item, FieldReAsk):
                 item.path = path + [idx]
@@ -70,42 +77,10 @@ def _gather_reasks_in_list(output: List, path: List[str] = []) -> None:
     return reasks
 
 
-def get_reasks_by_element(
-    reasks: List[FieldReAsk],
-    parsed_rail: ET._Element,
-) -> Dict[ET._Element, List[tuple]]:
-    """Cluster reasks by the XML element they are associated with."""
-    # This should be guaranteed to work, since the path corresponding
-    # to a ReAsk should always be valid in the element tree.
-
-    # This is because ReAsk objects are only created for elements
-    # with corresponding validators i.e. the element must have been
-    # in the tree in the first place for the ReAsk to be created.
-
-    reasks_by_element = defaultdict(list)
-
-    for reask in reasks:
-        path = reask.path
-        # TODO: does this work for all cases?
-        query = "."
-        for part in path:
-            if isinstance(part, int):
-                query += "/*"
-            else:
-                query += f"/*[@name='{part}']"
-
-        # Find the element
-        element = parsed_rail.find(query)
-
-        reasks_by_element[element].append(reask)
-
-    return reasks_by_element
-
-
 def get_pruned_tree(
-    root: ET._Element,
-    reask_elements: List[ET._Element] = None,
-) -> ET._Element:
+    root: ObjectType,
+    reasks: Optional[List[FieldReAsk]] = None,
+) -> ObjectType:
     """Prune tree of any elements that are not in `reasks`.
 
     Return the tree with only the elements that are keys of `reasks` and
@@ -119,34 +94,44 @@ def get_pruned_tree(
     Returns:
         The prompt.
     """
-    if reask_elements is None:
+    if reasks is None:
         return root
 
-    # Get all elements in `root`
-    elements = root.findall(".//*")
-    for element in elements:
-        if (element not in reask_elements) and len(element) == 0:
-            parent = element.getparent()
-            parent.remove(element)
-
-            # Remove all ancestors that have no children
-            while parent is not None and len(parent) == 0:
-                grandparent = parent.getparent()
-                if grandparent is not None:
-                    grandparent.remove(parent)
-                parent = grandparent
-
-    pruned_elements = root.findall(".//*")
-    for element in pruned_elements:
-        if element not in reask_elements:
-            # Remove the format attribute
-            if "format" in element.attrib:
-                del element.attrib["format"]
+    # Find all elements that are to be retained
+    retain = [root]
+    for reask in reasks:
+        path = reask.path
+        if path is None:
+            raise RuntimeError("FieldReAsk path is None")
+        current_root = root
+        for part in path:
+            # TODO does this work for all cases?
+            if isinstance(part, int):
+                current_root = current_root.children.item
+            else:
+                current_root = vars(current_root.children)[part]
+            retain.append(current_root)
+
+    # Remove all elements that are not to be retained
+    def _remove_children(element: Union[ObjectType, ListType]) -> None:
+        if isinstance(element, ListType):
+            if element.children.item not in retain:
+                del element._children["item"]
+            else:
+                _remove_children(element.children.item)
+        else:  # if isinstance(element, ObjectType):
+            for child_name, child in vars(element.children).items():
+                if child not in retain:
+                    del element._children[child_name]
+                else:
+                    _remove_children(child)
+
+    _remove_children(root)
 
     return root
 
 
-def prune_obj_for_reasking(obj: Any) -> Union[None, Dict, List]:
+def prune_obj_for_reasking(obj: Any) -> Union[None, Dict, List, ReAsk]:
     """After validation, we get a nested dictionary where some keys may be
     ReAsk objects.
 
@@ -159,9 +144,8 @@ def prune_obj_for_reasking(obj: Any) -> Union[None, Dict, List]:
     Returns:
         The pruned validated object.
     """
-    from guardrails.validators import PydanticReAsk
 
-    if isinstance(obj, ReAsk) or isinstance(obj, PydanticReAsk):
+    if isinstance(obj, ReAsk):
         return obj
     elif isinstance(obj, list):
         pruned_list = []
@@ -175,7 +159,7 @@ def prune_obj_for_reasking(obj: Any) -> Union[None, Dict, List]:
     elif isinstance(obj, dict):
         pruned_json = {}
         for key, value in obj.items():
-            if isinstance(value, FieldReAsk) or isinstance(value, PydanticReAsk):
+            if isinstance(value, FieldReAsk):
                 pruned_json[key] = value
             elif isinstance(value, dict):
                 pruned_output = prune_obj_for_reasking(value)
diff --git a/guardrails/utils/sql_utils.py b/guardrails/utils/sql_utils.py
index ea3c6f059..0f1994c9c 100644
--- a/guardrails/utils/sql_utils.py
+++ b/guardrails/utils/sql_utils.py
@@ -2,8 +2,6 @@
 from pathlib import Path
 from typing import List, Optional
 
-from pydantic import ValidationError
-
 try:
     import sqlalchemy
     from sqlalchemy import text
@@ -61,7 +59,7 @@ def __init__(self, schema_file: Optional[str], conn: Optional[str]) -> None:
             )
 
         if schema_file is not None and conn is None:
-            raise ValidationError(
+            raise RuntimeError(
                 """schema_file should accompany a sql connection string for
            guardrails to apply it to a database backend.
            Use sqlite for ex: sqlite://"""
@@ -76,8 +74,8 @@ def __init__(self, schema_file: Optional[str], conn: Optional[str]) -> None:
 
         if schema_file is not None:
             schema = Path(schema_file).read_text()
-            if conn.startswith("sqlite"):
-                self._conn.connection.executescript(schema)
+            if conn is not None and conn.startswith("sqlite"):
+                self._conn.connection.executescript(schema)  # type: ignore
             else:
                 from sqlalchemy import text
 
diff --git a/guardrails/utils/xml_utils.py b/guardrails/utils/xml_utils.py
new file mode 100644
index 000000000..3d0087288
--- /dev/null
+++ b/guardrails/utils/xml_utils.py
@@ -0,0 +1,17 @@
+from typing import Union
+
+
+def cast_xml_to_string(xml_value: Union[memoryview, bytes, bytearray, str]) -> str:
+    """Cast XML value to a string.
+
+    Args:
+        xml_value (Union[memoryview, bytes, bytearray, str]): The XML value to cast.
+
+    Returns:
+        str: The XML value as a string.
+    """
+    if isinstance(xml_value, memoryview):
+        xml_value = xml_value.tobytes().decode()
+    elif isinstance(xml_value, (bytes, bytearray)):
+        xml_value = xml_value.decode()
+    return xml_value
diff --git a/guardrails/validator_base.py b/guardrails/validator_base.py
new file mode 100644
index 000000000..12e19b354
--- /dev/null
+++ b/guardrails/validator_base.py
@@ -0,0 +1,271 @@
+from collections import defaultdict
+from typing import Any, Callable, Dict, List, Literal, Optional, Type, Union
+
+from pydantic import BaseModel, Field
+
+
+class ValidatorError(Exception):
+    """Base class for all validator errors."""
+
+
+class Filter:
+    pass
+
+
+class Refrain:
+    pass
+
+
+def check_refrain_in_list(schema: List) -> bool:
+    """Checks if a Refrain object exists in a list.
+
+    Args:
+        schema: A list that can contain lists, dicts or scalars.
+
+    Returns:
+        bool: True if a Refrain object exists in the list.
+    """
+    for item in schema:
+        if isinstance(item, Refrain):
+            return True
+        elif isinstance(item, list):
+            if check_refrain_in_list(item):
+                return True
+        elif isinstance(item, dict):
+            if check_refrain_in_dict(item):
+                return True
+
+    return False
+
+
+def check_refrain_in_dict(schema: Dict) -> bool:
+    """Checks if a Refrain object exists in a dict.
+
+    Args:
+        schema: A dict that can contain lists, dicts or scalars.
+
+    Returns:
+        True if a Refrain object exists in the dict.
+    """
+    for key, value in schema.items():
+        if isinstance(value, Refrain):
+            return True
+        elif isinstance(value, list):
+            if check_refrain_in_list(value):
+                return True
+        elif isinstance(value, dict):
+            if check_refrain_in_dict(value):
+                return True
+
+    return False
+
+
+def filter_in_list(schema: List) -> List:
+    """Remove out all Filter objects from a list.
+
+    Args:
+        schema: A list that can contain lists, dicts or scalars.
+
+    Returns:
+        A list with all Filter objects removed.
+    """
+    filtered_list = []
+
+    for item in schema:
+        if isinstance(item, Filter):
+            pass
+        elif isinstance(item, list):
+            filtered_item = filter_in_list(item)
+            if len(filtered_item):
+                filtered_list.append(filtered_item)
+        elif isinstance(item, dict):
+            filtered_dict = filter_in_dict(item)
+            if len(filtered_dict):
+                filtered_list.append(filtered_dict)
+        else:
+            filtered_list.append(item)
+
+    return filtered_list
+
+
+def filter_in_dict(schema: Dict) -> Dict:
+    """Remove out all Filter objects from a dictionary.
+
+    Args:
+        schema: A dictionary that can contain lists, dicts or scalars.
+
+    Returns:
+        A dictionary with all Filter objects removed.
+    """
+    filtered_dict = {}
+
+    for key, value in schema.items():
+        if isinstance(value, Filter):
+            pass
+        elif isinstance(value, list):
+            filtered_item = filter_in_list(value)
+            if len(filtered_item):
+                filtered_dict[key] = filtered_item
+        elif isinstance(value, dict):
+            filtered_dict[key] = filter_in_dict(value)
+        else:
+            filtered_dict[key] = value
+
+    return filtered_dict
+
+
+validators_registry = {}
+types_to_validators = defaultdict(list)
+
+
+def register_validator(name: str, data_type: Union[str, List[str]]):
+    """Register a validator for a data type."""
+    from guardrails.datatypes import registry as types_registry
+
+    if isinstance(data_type, str):
+        data_type = list(types_registry.keys()) if data_type == "all" else [data_type]
+    # Make sure that the data type string exists in the data types registry.
+    for dt in data_type:
+        if dt not in types_registry:
+            raise ValueError(f"Data type {dt} is not registered.")
+
+        types_to_validators[dt].append(name)
+
+    def decorator(cls_or_func: Union[Type[Validator], Callable]):
+        """Register a validator for a data type."""
+        if isinstance(cls_or_func, type) and issubclass(cls_or_func, Validator):
+            cls = cls_or_func
+            cls.rail_alias = name
+        elif callable(cls_or_func) and not isinstance(cls_or_func, type):
+            func = cls_or_func
+            func.rail_alias = name  # type: ignore
+            # ensure function takes two args
+            if not func.__code__.co_argcount == 2:
+                raise ValueError(
+                    f"Validator function {func.__name__} must take two arguments."
+                )
+            # dynamically create Validator subclass with `validate` method as `func`
+            cls = type(
+                name,
+                (Validator,),
+                {"validate": staticmethod(func), "rail_alias": name},
+            )
+        else:
+            raise ValueError(
+                "Only functions and Validator subclasses "
+                "can be registered as validators."
+            )
+        validators_registry[name] = cls
+        return cls
+
+    return decorator
+
+
+class ValidationResult(BaseModel):
+    outcome: str
+    metadata: Optional[Dict[str, Any]] = None
+
+
+class PassResult(ValidationResult):
+    outcome: Literal["pass"] = "pass"
+
+    class ValueOverrideSentinel:
+        pass
+
+    # should only be used if Validator.override_value_on_pass is True
+    value_override: Optional[Any] = Field(default=ValueOverrideSentinel)
+
+
+class FailResult(ValidationResult):
+    outcome: Literal["fail"] = "fail"
+
+    error_message: str
+    fix_value: Optional[Any] = None
+
+
+class Validator:
+    """Base class for validators."""
+
+    rail_alias: str
+
+    run_in_separate_process = False
+    override_value_on_pass = False
+    required_metadata_keys = []
+
+    def __init__(self, on_fail: Optional[Union[Callable, str]] = None, **kwargs):
+        if on_fail is None:
+            on_fail = "noop"
+        if isinstance(on_fail, str):
+            self.on_fail_descriptor = on_fail
+            self.on_fail_method = None
+        else:
+            self.on_fail_descriptor = "custom"
+            self.on_fail_method = on_fail
+
+        # Store the kwargs for the validator.
+        self._kwargs = kwargs
+
+        assert (
+            self.rail_alias in validators_registry
+        ), f"Validator {self.__class__.__name__} is not registered. "
+
+    def validate(self, value: Any, metadata: Dict[str, Any]) -> ValidationResult:
+        """Validates a value and return a validation result."""
+        raise NotImplementedError
+
+    def to_prompt(self, with_keywords: bool = True) -> str:
+        """Convert the validator to a prompt.
+
+        E.g. ValidLength(5, 10) -> "length: 5 10" when with_keywords is False.
+        ValidLength(5, 10) -> "length: min=5 max=10" when with_keywords is True.
+
+        Args:
+            with_keywords: Whether to include the keyword arguments in the prompt.
+
+        Returns:
+            A string representation of the validator.
+        """
+        if not len(self._kwargs):
+            return self.rail_alias
+
+        kwargs = self._kwargs.copy()
+        for k, v in kwargs.items():
+            if not isinstance(v, str):
+                kwargs[k] = str(v)
+
+        params = " ".join(list(kwargs.values()))
+        if with_keywords:
+            params = " ".join([f"{k}={v}" for k, v in kwargs.items()])
+        return f"{self.rail_alias}: {params}"
+
+    def to_xml_attrib(self):
+        """Convert the validator to an XML attribute."""
+
+        if not len(self._kwargs):
+            return self.rail_alias
+
+        validator_args = []
+        for arg in self.__init__.__code__.co_varnames[1:]:
+            if arg not in ("on_fail", "args", "kwargs"):
+                str_arg = str(self._kwargs[arg])
+                str_arg = "{" + str_arg + "}" if " " in str_arg else str_arg
+                validator_args.append(str_arg)
+
+        params = " ".join(validator_args)
+        return f"{self.rail_alias}: {params}"
+
+    def __call__(self, value):
+        result = self.validate(value, {})
+        if isinstance(result, FailResult):
+            from guardrails.validator_service import ValidatorServiceBase
+
+            validator_service = ValidatorServiceBase()
+            return validator_service.perform_correction(
+                [result], value, self, self.on_fail_descriptor
+            )
+        return value
+
+    def __eq__(self, other):
+        if not isinstance(other, Validator):
+            return False
+        return self.to_prompt() == other.to_prompt()
diff --git a/guardrails/validator_service.py b/guardrails/validator_service.py
index 260ba4979..e3cd0631e 100644
--- a/guardrails/validator_service.py
+++ b/guardrails/validator_service.py
@@ -3,17 +3,16 @@
 import logging
 import os
 from concurrent.futures import ProcessPoolExecutor
-from typing import Any, Dict, List, Tuple
+from typing import Any, Dict, List, Optional, Tuple
 
 from guardrails.datatypes import FieldValidation
 from guardrails.utils.logs_utils import FieldValidationLogs, ValidatorLogs
 from guardrails.utils.reask_utils import FieldReAsk, ReAsk
 from guardrails.utils.safe_get import safe_get
-from guardrails.validators import (
+from guardrails.validator_base import (
     FailResult,
     Filter,
     PassResult,
-    PydanticReAsk,
     Refrain,
     Validator,
     ValidatorError,
@@ -36,9 +35,7 @@ def perform_correction(
             return results[0].fix_value
         elif on_fail_descriptor == "fix_reask":
             fixed_value = results[0].fix_value
-            result = validator.validate(fixed_value, results[0].metadata)
-            if result.metadata is None:
-                result.metadata = result.metadata
+            result = validator.validate(fixed_value, results[0].metadata or {})
 
             if isinstance(result, FailResult):
                 return FieldReAsk(
@@ -48,6 +45,8 @@ def perform_correction(
 
             return fixed_value
         if on_fail_descriptor == "custom":
+            if validator.on_fail_method is None:
+                raise ValueError("on_fail is 'custom' but on_fail_method is None")
             return validator.on_fail_method(value, results[0])
         if on_fail_descriptor == "reask":
             return FieldReAsk(
@@ -96,7 +95,7 @@ def run_validators(
         validator_setup: FieldValidation,
         value: Any,
         metadata: Dict[str, Any],
-    ):
+    ) -> Tuple[Any, Dict[str, Any]]:
         # Validate the field
         for validator in validator_setup.validators:
             validator_logs = self.run_validator(
@@ -118,9 +117,10 @@ def run_validators(
                 raise RuntimeError(f"Unexpected result type {type(result)}")
 
             validator_logs.value_after_validation = value
-            metadata = validator_logs.validation_result.metadata
+            if result.metadata is not None:
+                metadata = result.metadata
 
-            if isinstance(value, (Refrain, Filter, ReAsk, PydanticReAsk)):
+            if isinstance(value, (Refrain, Filter, ReAsk)):
                 return value, metadata
         return value, metadata
 
@@ -157,7 +157,7 @@ def validate(
 
 
 class MultiprocMixin:
-    multiprocessing_executor: ProcessPoolExecutor = None
+    multiprocessing_executor: Optional[ProcessPoolExecutor] = None
     process_count = int(os.environ.get("GUARDRAILS_PROCESS_COUNT", 10))
 
     def __init__(self):
@@ -238,7 +238,7 @@ async def run_validators(self, validation_logs, validator_setup, value, metadata
                 logs.value_after_validation = value
 
             # return early if we have a filter, refrain, or reask
-            if isinstance(value, (Filter, Refrain, FieldReAsk, PydanticReAsk)):
+            if isinstance(value, (Filter, Refrain, FieldReAsk)):
                 return value, metadata
 
         return value, metadata
diff --git a/guardrails/validators.py b/guardrails/validators.py
index fa236be08..506516af0 100644
--- a/guardrails/validators.py
+++ b/guardrails/validators.py
@@ -12,21 +12,23 @@
 import re
 import string
 import warnings
-from collections import defaultdict
 from functools import partial
-from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, Union
+from typing import Any, Callable, Dict, List, Optional, Tuple, Union
 
 import openai
-import pydantic
 import rstr
-from pydantic import Field
 from tenacity import retry, stop_after_attempt, wait_random_exponential
 
-from guardrails.utils.casting_utils import to_int
 from guardrails.utils.docs_utils import get_chunks_from_text, sentence_split
-from guardrails.utils.json_utils import deprecated_string_types
 from guardrails.utils.sql_utils import SQLDriver, create_sql_driver
 from guardrails.utils.validator_utils import PROVENANCE_V1_PROMPT
+from guardrails.validator_base import (
+    FailResult,
+    PassResult,
+    ValidationResult,
+    Validator,
+    register_validator,
+)
 
 try:
     import numpy as np
@@ -36,295 +38,25 @@
     _HAS_NUMPY = True
 
 try:
-    import detect_secrets
+    import detect_secrets  # type: ignore
 except ImportError:
-    detect_secrets = None
+    detect_secrets = None  # type: ignore
 
 try:
-    import nltk
+    import nltk  # type: ignore
 except ImportError:
-    nltk = None
+    nltk = None  # type: ignore
 
-try:
-    if nltk is not None:
+if nltk is not None:
+    try:
         nltk.data.find("tokenizers/punkt")
-except LookupError:
-    nltk.download("punkt")
-
-
-validators_registry = {}
-types_to_validators = defaultdict(list)
+    except LookupError:
+        nltk.download("punkt")
 
 
 logger = logging.getLogger(__name__)
 
 
-class ValidatorError(Exception):
-    """Base class for all validator errors."""
-
-
-class Filter:
-    pass
-
-
-class Refrain:
-    pass
-
-
-def check_refrain_in_list(schema: List) -> bool:
-    """Checks if a Refrain object exists in a list.
-
-    Args:
-        schema: A list that can contain lists, dicts or scalars.
-
-    Returns:
-        bool: True if a Refrain object exists in the list.
-    """
-    for item in schema:
-        if isinstance(item, Refrain):
-            return True
-        elif isinstance(item, list):
-            if check_refrain_in_list(item):
-                return True
-        elif isinstance(item, dict):
-            if check_refrain_in_dict(item):
-                return True
-
-    return False
-
-
-def check_refrain_in_dict(schema: Dict) -> bool:
-    """Checks if a Refrain object exists in a dict.
-
-    Args:
-        schema: A dict that can contain lists, dicts or scalars.
-
-    Returns:
-        True if a Refrain object exists in the dict.
-    """
-    for key, value in schema.items():
-        if isinstance(value, Refrain):
-            return True
-        elif isinstance(value, list):
-            if check_refrain_in_list(value):
-                return True
-        elif isinstance(value, dict):
-            if check_refrain_in_dict(value):
-                return True
-
-    return False
-
-
-def filter_in_list(schema: List) -> List:
-    """Remove out all Filter objects from a list.
-
-    Args:
-        schema: A list that can contain lists, dicts or scalars.
-
-    Returns:
-        A list with all Filter objects removed.
-    """
-    filtered_list = []
-
-    for item in schema:
-        if isinstance(item, Filter):
-            pass
-        elif isinstance(item, PydanticReAsk):
-            filtered_list.append(item)
-        elif isinstance(item, list):
-            filtered_item = filter_in_list(item)
-            if len(filtered_item):
-                filtered_list.append(filtered_item)
-        elif isinstance(item, dict):
-            filtered_dict = filter_in_dict(item)
-            if len(filtered_dict):
-                filtered_list.append(filtered_dict)
-        else:
-            filtered_list.append(item)
-
-    return filtered_list
-
-
-def filter_in_dict(schema: Dict) -> Dict:
-    """Remove out all Filter objects from a dictionary.
-
-    Args:
-        schema: A dictionary that can contain lists, dicts or scalars.
-
-    Returns:
-        A dictionary with all Filter objects removed.
-    """
-    filtered_dict = {}
-
-    for key, value in schema.items():
-        if isinstance(value, Filter):
-            pass
-        elif isinstance(value, PydanticReAsk):
-            filtered_dict[key] = value
-        elif isinstance(value, list):
-            filtered_item = filter_in_list(value)
-            if len(filtered_item):
-                filtered_dict[key] = filtered_item
-        elif isinstance(value, dict):
-            filtered_dict[key] = filter_in_dict(value)
-        else:
-            filtered_dict[key] = value
-
-    return filtered_dict
-
-
-def register_validator(name: str, data_type: Union[str, List[str]]):
-    """Register a validator for a data type."""
-    from guardrails.datatypes import registry as types_registry
-
-    if isinstance(data_type, str):
-        data_type = list(types_registry.keys()) if data_type == "all" else [data_type]
-    # Make sure that the data type string exists in the data types registry.
-    for dt in data_type:
-        if dt not in types_registry:
-            raise ValueError(f"Data type {dt} is not registered.")
-        if dt == "string":
-            for str_type in deprecated_string_types:
-                types_to_validators[str_type].append(name)
-        types_to_validators[dt].append(name)
-
-    def decorator(cls_or_func: Union[type, Callable]):
-        """Register a validator for a data type."""
-        if isinstance(cls_or_func, type(Validator)) or issubclass(
-            type(cls_or_func), Validator
-        ):
-            cls = cls_or_func
-            cls.rail_alias = name
-        elif callable(cls_or_func):
-            func = cls_or_func
-            func.rail_alias = name
-            # ensure function takes two args
-            if not func.__code__.co_argcount == 2:
-                raise ValueError(
-                    f"Validator function {func.__name__} must take two arguments."
-                )
-            # dynamically create Validator subclass with `validate` method as `func`
-            cls = type(
-                name,
-                (Validator,),
-                {"validate": staticmethod(func), "rail_alias": name},
-            )
-        else:
-            raise ValueError(
-                "Only classes and functions can be registered as validators."
-            )
-        validators_registry[name] = cls
-        return cls
-
-    return decorator
-
-
-class ValidationResult(pydantic.BaseModel):
-    outcome: str
-    metadata: Optional[Dict[str, Any]] = None
-
-
-class PassResult(ValidationResult):
-    outcome: Literal["pass"] = "pass"
-
-    class ValueOverrideSentinel:
-        pass
-
-    # should only be used if Validator.override_value_on_pass is True
-    value_override: Optional[Any] = Field(default=ValueOverrideSentinel)
-
-
-class FailResult(ValidationResult):
-    outcome: Literal["fail"] = "fail"
-
-    error_message: str
-    fix_value: Optional[Any] = None
-
-
-class Validator:
-    """Base class for validators."""
-
-    run_in_separate_process = False
-    override_value_on_pass = False
-    required_metadata_keys = []
-
-    def __init__(self, on_fail: Optional[Callable] = None, **kwargs):
-        if on_fail is None:
-            on_fail = "noop"
-        if isinstance(on_fail, str):
-            self.on_fail_descriptor = on_fail
-            self.on_fail_method = None
-        else:
-            self.on_fail_descriptor = "custom"
-            self.on_fail_method = on_fail
-
-        # Store the kwargs for the validator.
-        self._kwargs = kwargs
-
-        assert (
-            self.rail_alias in validators_registry
-        ), f"Validator {self.__class__.__name__} is not registered. "
-
-    def validate(self, value: Any, metadata: Dict[str, Any]) -> ValidationResult:
-        """Validates a value and return a validation result."""
-        raise NotImplementedError
-
-    def to_prompt(self, with_keywords: bool = True) -> str:
-        """Convert the validator to a prompt.
-
-        E.g. ValidLength(5, 10) -> "length: 5 10" when with_keywords is False.
-        ValidLength(5, 10) -> "length: min=5 max=10" when with_keywords is True.
-
-        Args:
-            with_keywords: Whether to include the keyword arguments in the prompt.
-
-        Returns:
-            A string representation of the validator.
-        """
-        if not len(self._kwargs):
-            return self.rail_alias
-
-        kwargs = self._kwargs.copy()
-        for k, v in kwargs.items():
-            if not isinstance(v, str):
-                kwargs[k] = str(v)
-
-        params = " ".join(list(kwargs.values()))
-        if with_keywords:
-            params = " ".join([f"{k}={v}" for k, v in kwargs.items()])
-        return f"{self.rail_alias}: {params}"
-
-    def to_xml_attrib(self):
-        """Convert the validator to an XML attribute."""
-
-        if not len(self._kwargs):
-            return self.rail_alias
-
-        validator_args = []
-        init_args = inspect.getfullargspec(self.__init__)
-        for arg in init_args.args[1:]:
-            if arg not in ("on_fail", "args", "kwargs"):
-                arg_value = self._kwargs.get(arg)
-                str_arg = str(arg_value)
-                if str_arg is not None:
-                    str_arg = "{" + str_arg + "}" if " " in str_arg else str_arg
-                    validator_args.append(str_arg)
-
-        params = " ".join(validator_args)
-        return f"{self.rail_alias}: {params}"
-
-    def __call__(self, value):
-        result = self.validate(value, {})
-        if isinstance(result, FailResult):
-            from guardrails.validator_service import ValidatorServiceBase
-
-            validator_service = ValidatorServiceBase()
-            return validator_service.perform_correction(
-                [result], value, self, self.on_fail_descriptor
-            )
-        return value
-
-
 # @register_validator('required', 'all')
 # class Required(Validator):
 #     """Validates that a value is not None."""
@@ -345,10 +77,6 @@ def __call__(self, value):
 #         return value is not None
 
 
-class PydanticReAsk(dict):
-    pass
-
-
 @register_validator(name="pydantic_field_validator", data_type="all")
 class PydanticFieldValidator(Validator):
     """Validates a specific field in a Pydantic model with the specified
@@ -412,7 +140,10 @@ class ValidRange(Validator):
     """
 
     def __init__(
-        self, min: int = None, max: int = None, on_fail: Optional[Callable] = None
+        self,
+        min: Optional[int] = None,
+        max: Optional[int] = None,
+        on_fail: Optional[Callable] = None,
     ):
         super().__init__(on_fail=on_fail, min=min, max=max)
 
@@ -540,13 +271,16 @@ class ValidLength(Validator):
     """  # noqa
 
     def __init__(
-        self, min: int = None, max: int = None, on_fail: Optional[Callable] = None
+        self,
+        min: Optional[int] = None,
+        max: Optional[int] = None,
+        on_fail: Optional[Callable] = None,
     ):
         super().__init__(on_fail=on_fail, min=min, max=max)
-        self._min = to_int(min)
-        self._max = to_int(max)
+        self._min = int(min) if min is not None else None
+        self._max = int(max) if max is not None else None
 
-    def validate(self, value: Any, metadata: Dict) -> ValidationResult:
+    def validate(self, value: Union[str, List], metadata: Dict) -> ValidationResult:
         """Validates that the length of value is within the expected range."""
         logger.debug(
             f"Validating {value} is in length range {self._min} - {self._max}..."
@@ -561,12 +295,14 @@ def validate(self, value: Any, metadata: Dict) -> ValidationResult:
                     last_val = rstr.rstr(string.ascii_lowercase, 1)
                 else:
                     last_val = value[-1]
+                corrected_value = value + last_val * (self._min - len(value))
             else:
                 if not value:
                     last_val = [rstr.rstr(string.ascii_lowercase, 1)]
                 else:
                     last_val = [value[-1]]
-            corrected_value = value + last_val * (self._min - len(value))
+                # extend value by padding it out with last_val
+                corrected_value = value.extend([last_val] * (self._min - len(value)))
 
             return FailResult(
                 error_message=f"Value has length less than {self._min}. "
@@ -610,24 +346,31 @@ def __init__(
         match_type: Optional[str] = None,
         on_fail: Optional[Callable] = None,
     ):
-        match_types = ["fullmatch", "search"]
+        # todo -> something forces this to be passed as kwargs and therefore xml-ized.
+        # match_types = ["fullmatch", "search"]
+
         if match_type is None:
             match_type = "fullmatch"
-        assert match_type in match_types, f"match_type must be in {match_types}"
+        assert match_type in [
+            "fullmatch",
+            "search",
+        ], 'match_type must be in ["fullmatch", "search"]'
+
         super().__init__(on_fail=on_fail, match_type=match_type, regex=regex)
         self._regex = regex
-        self._p = re.compile(regex)
-        self._match_f = getattr(self._p, match_type)
+        self._match_type = match_type
+
+    def validate(self, value: Any, metadata: Dict) -> ValidationResult:
+        p = re.compile(self._regex)
+        """Validates that value matches the provided regular expression."""
         # Pad matching string on either side for fix
         # example if we are performing a regex search
         str_padding = (
-            "" if match_type == "fullmatch" else rstr.rstr(string.ascii_lowercase)
+            "" if self._match_type == "fullmatch" else rstr.rstr(string.ascii_lowercase)
         )
-        self._fix_str = str_padding + rstr.xeger(regex) + str_padding
+        self._fix_str = str_padding + rstr.xeger(self._regex) + str_padding
 
-    def validate(self, value: Any, metadata: Dict) -> ValidationResult:
-        """Validates that value matches the provided regular expression."""
-        if not self._match_f(value):
+        if not getattr(p, self._match_type)(value):
             return FailResult(
                 error_message=f"Result must match {self._regex}",
                 fix_value=self._fix_str,
@@ -858,6 +601,8 @@ def validate(self, value: Any, metadata: Dict) -> ValidationResult:
         expressions = parse(value)
         cols = set()
         for expression in expressions:
+            if expression is None:
+                continue
             for col in expression.find_all(exp.Column):
                 cols.add(col.alias_or_name)
 
@@ -951,9 +696,8 @@ def __init__(
             )
 
         self._document = document
-        embedding = openai.Embedding.create(input=[document], model=model)["data"][0][
-            "embedding"
-        ]
+        embedding_response = openai.Embedding.create(input=[document], model=model)
+        embedding = embedding_response["data"][0]["embedding"]  # type: ignore
         self._document_embedding = np.array(embedding)
         self._model = model
         self._threshold = float(threshold)
@@ -974,13 +718,13 @@ def cosine_similarity(a: "np.ndarray", b: "np.ndarray") -> float:
     def validate(self, value: Any, metadata: Dict) -> ValidationResult:
         logger.debug(f"Validating {value} is similar to document...")
 
+        embedding_response = openai.Embedding.create(input=[value], model=self._model)
+
         value_embedding = np.array(
-            openai.Embedding.create(input=[value], model=self._model)["data"][0][
-                "embedding"
-            ]
+            embedding_response["data"][0]["embedding"]  # type: ignore
         )
 
-        similarity = SimilarToDocument.cosine_similarity(
+        similarity = self.cosine_similarity(
             self._document_embedding,
             value_embedding,
         )
@@ -1014,7 +758,7 @@ class IsProfanityFree(Validator):
 
     def validate(self, value: Any, metadata: Dict) -> ValidationResult:
         try:
-            from profanity_check import predict
+            from profanity_check import predict  # type: ignore
         except ImportError:
             raise ImportError(
                 "`is-profanity-free` validator requires the `alt-profanity-check`"
@@ -1052,7 +796,7 @@ class IsHighQualityTranslation(Validator):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         try:
-            from inspiredco.critique import Critique
+            from inspiredco.critique import Critique  # type: ignore
 
             self._critique = Critique(api_key=os.environ["INSPIREDCO_API_KEY"])
 
@@ -1354,7 +1098,7 @@ def validate(self, value: Any, metadata: Dict) -> ValidationResult:
             store[filepath] = sentence_split(doc)
 
         try:
-            from thefuzz import fuzz
+            from thefuzz import fuzz  # type: ignore
         except ImportError:
             raise ImportError(
                 "`thefuzz` library is required for `extractive-summary` validator. "
@@ -1449,7 +1193,7 @@ def validate(self, value: Any, metadata: Dict) -> ValidationResult:
         """Remove redundant sentences from a string."""
 
         try:
-            from thefuzz import fuzz
+            from thefuzz import fuzz  # type: ignore
         except ImportError:
             raise ImportError(
                 "`thefuzz` library is required for `remove-redundant-sentences` "
@@ -1515,7 +1259,7 @@ class SaliencyCheck(Validator):
     def __init__(
         self,
         docs_dir: str,
-        llm_callable: Callable = None,
+        llm_callable: Optional[Callable] = None,
         on_fail: Optional[Callable] = None,
         threshold: float = 0.25,
         **kwargs,
@@ -1530,6 +1274,11 @@ def __init__(
 
         super().__init__(on_fail, **kwargs)
 
+        if llm_callable is not None and inspect.iscoroutinefunction(llm_callable):
+            raise ValueError(
+                "SaliencyCheck validator does not support async LLM callables."
+            )
+
         self.llm_callable = (
             llm_callable if llm_callable else openai.ChatCompletion.create
         )
@@ -1631,11 +1380,17 @@ class QARelevanceLLMEval(Validator):
 
     def __init__(
         self,
-        llm_callable: Callable = None,
+        llm_callable: Optional[Callable] = None,
         on_fail: Optional[Callable] = None,
         **kwargs,
     ):
         super().__init__(on_fail, **kwargs)
+
+        if llm_callable is not None and inspect.iscoroutinefunction(llm_callable):
+            raise ValueError(
+                "QARelevanceLLMEval validator does not support async LLM callables."
+            )
+
         self.llm_callable = (
             llm_callable if llm_callable else openai.ChatCompletion.create
         )
@@ -1708,15 +1463,12 @@ class ProvenanceV0(Validator):
     Parameters: Arguments
         threshold: The minimum cosine similarity between the generated text and
             the source text. Defaults to 0.8.
-        validation_method: Whether to validate at the sentence level or over the full text.
-            Must be one of `sentence` or `full`. Defaults to `sentence`
+        validation_method: Whether to validate at the sentence level or over the full text.  Must be one of `sentence` or `full`. Defaults to `sentence`
 
     Other parameters: Metadata
-        query_function (Callable, optional): A callable that takes a string and returns
-            a list of (chunk, score) tuples.
+        query_function (Callable, optional): A callable that takes a string and returns a list of (chunk, score) tuples.
         sources (List[str], optional): The source text.
-        embed_function (Callable, optional): A callable that creates embeddings for the
-            sources. Must accept a list of strings and return an np.array of floats.
+        embed_function (Callable, optional): A callable that creates embeddings for the sources. Must accept a list of strings and return an np.array of floats.
 
     In order to use this validator, you must provide either a `query_function` or
     `sources` with an `embed_function` in the metadata.
@@ -1782,60 +1534,66 @@ def __init__(
             raise ValueError("validation_method must be 'sentence' or 'full'.")
         self._validation_method = validation_method
 
-    def get_query_function(self, metadata: Dict[str, Any]) -> None:
+    def get_query_function(self, metadata: Dict[str, Any]) -> Callable:
         query_fn = metadata.get("query_function", None)
         sources = metadata.get("sources", None)
 
         # Check that query_fn or sources are provided
-        if query_fn is not None and sources is not None:
-            warnings.warn(
-                "Both `query_function` and `sources` are provided in metadata. "
-                "`query_function` will be used."
-            )
-        elif query_fn is None and sources is None:
+        if query_fn is not None:
+            if sources is not None:
+                warnings.warn(
+                    "Both `query_function` and `sources` are provided in metadata. "
+                    "`query_function` will be used."
+                )
+            return query_fn
+
+        if sources is None:
             raise ValueError(
                 "You must provide either `query_function` or `sources` in metadata."
             )
-        elif query_fn is None and sources is not None:
-            # Check chunking strategy
-            chunk_strategy = metadata.get("chunk_strategy", "sentence")
-            if chunk_strategy not in ["sentence", "word", "char", "token"]:
-                raise ValueError(
-                    "`chunk_strategy` must be one of 'sentence', 'word', 'char', "
-                    "or 'token'."
-                )
-            chunk_size = metadata.get("chunk_size", 5)
-            chunk_overlap = metadata.get("chunk_overlap", 2)
 
-            # Check distance metric
-            distance_metric = metadata.get("distance_metric", "cosine")
-            if distance_metric not in ["cosine", "euclidean"]:
-                raise ValueError(
-                    "`distance_metric` must be one of 'cosine' or 'euclidean'."
-                )
+        # Check chunking strategy
+        chunk_strategy = metadata.get("chunk_strategy", "sentence")
+        if chunk_strategy not in ["sentence", "word", "char", "token"]:
+            raise ValueError(
+                "`chunk_strategy` must be one of 'sentence', 'word', 'char', "
+                "or 'token'."
+            )
+        chunk_size = metadata.get("chunk_size", 5)
+        chunk_overlap = metadata.get("chunk_overlap", 2)
 
-            # Check embed model
-            embed_function = metadata.get("embed_function", None)
-            if embed_function is None:
-                raise ValueError(
-                    "You must provide `embed_function` in metadata in order to "
-                    "use the default query function."
-                )
-            query_fn = partial(
-                ProvenanceV0.query_vector_collection,
-                sources=metadata["sources"],
-                chunk_strategy=chunk_strategy,
-                chunk_size=chunk_size,
-                chunk_overlap=chunk_overlap,
-                distance_metric=distance_metric,
-                embed_function=embed_function,
+        # Check distance metric
+        distance_metric = metadata.get("distance_metric", "cosine")
+        if distance_metric not in ["cosine", "euclidean"]:
+            raise ValueError(
+                "`distance_metric` must be one of 'cosine' or 'euclidean'."
             )
 
-        return query_fn
+        # Check embed model
+        embed_function = metadata.get("embed_function", None)
+        if embed_function is None:
+            raise ValueError(
+                "You must provide `embed_function` in metadata in order to "
+                "use the default query function."
+            )
+        return partial(
+            self.query_vector_collection,
+            sources=metadata["sources"],
+            chunk_strategy=chunk_strategy,
+            chunk_size=chunk_size,
+            chunk_overlap=chunk_overlap,
+            distance_metric=distance_metric,
+            embed_function=embed_function,
+        )
 
     def validate_each_sentence(
         self, value: Any, query_function: Callable, metadata: Dict[str, Any]
     ) -> ValidationResult:
+        if nltk is None:
+            raise ImportError(
+                "`nltk` library is required for `provenance-v0` validator. "
+                "Please install it with `pip install nltk`."
+            )
         # Split the value into sentences using nltk sentence tokenizer.
         sentences = nltk.sent_tokenize(value)
 
@@ -1914,11 +1672,11 @@ def query_vector_collection(
         text: str,
         k: int,
         sources: List[str],
+        embed_function: Callable,
         chunk_strategy: str = "sentence",
         chunk_size: int = 5,
         chunk_overlap: int = 2,
         distance_metric: str = "cosine",
-        embed_function: Optional[Callable] = None,
     ) -> List[Tuple[str, float]]:
         chunks = [
             get_chunks_from_text(source, chunk_strategy, chunk_size, chunk_overlap)
@@ -2085,7 +1843,7 @@ def openai_callable(prompt: str) -> str:
                     ],
                     max_tokens=self._max_tokens,
                 )
-                return response["choices"][0]["message"]["content"]
+                return response["choices"][0]["message"]["content"]  # type: ignore
 
             self._llm_callable = openai_callable
         elif isinstance(llm_callable, Callable):
@@ -2096,58 +1854,59 @@ def openai_callable(prompt: str) -> str:
                 " and returns a string."
             )
 
-    def get_query_function(self, metadata: Dict[str, Any]) -> None:
+    def get_query_function(self, metadata: Dict[str, Any]) -> Callable:
         # Exact same as ProvenanceV0
 
         query_fn = metadata.get("query_function", None)
         sources = metadata.get("sources", None)
 
         # Check that query_fn or sources are provided
-        if query_fn is not None and sources is not None:
-            warnings.warn(
-                "Both `query_function` and `sources` are provided in metadata. "
-                "`query_function` will be used."
-            )
-        elif query_fn is None and sources is None:
+        if query_fn is not None:
+            if sources is not None:
+                warnings.warn(
+                    "Both `query_function` and `sources` are provided in metadata. "
+                    "`query_function` will be used."
+                )
+            return query_fn
+
+        if sources is None:
             raise ValueError(
                 "You must provide either `query_function` or `sources` in metadata."
             )
-        elif query_fn is None and sources is not None:
-            # Check chunking strategy
-            chunk_strategy = metadata.get("chunk_strategy", "sentence")
-            if chunk_strategy not in ["sentence", "word", "char", "token"]:
-                raise ValueError(
-                    "`chunk_strategy` must be one of 'sentence', 'word', 'char', "
-                    "or 'token'."
-                )
-            chunk_size = metadata.get("chunk_size", 5)
-            chunk_overlap = metadata.get("chunk_overlap", 2)
 
-            # Check distance metric
-            distance_metric = metadata.get("distance_metric", "cosine")
-            if distance_metric not in ["cosine", "euclidean"]:
-                raise ValueError(
-                    "`distance_metric` must be one of 'cosine' or 'euclidean'."
-                )
+        # Check chunking strategy
+        chunk_strategy = metadata.get("chunk_strategy", "sentence")
+        if chunk_strategy not in ["sentence", "word", "char", "token"]:
+            raise ValueError(
+                "`chunk_strategy` must be one of 'sentence', 'word', 'char', "
+                "or 'token'."
+            )
+        chunk_size = metadata.get("chunk_size", 5)
+        chunk_overlap = metadata.get("chunk_overlap", 2)
 
-            # Check embed model
-            embed_function = metadata.get("embed_function", None)
-            if embed_function is None:
-                raise ValueError(
-                    "You must provide `embed_function` in metadata in order to "
-                    "use the default query function."
-                )
-            query_fn = partial(
-                ProvenanceV1.query_vector_collection,
-                sources=metadata["sources"],
-                chunk_strategy=chunk_strategy,
-                chunk_size=chunk_size,
-                chunk_overlap=chunk_overlap,
-                distance_metric=distance_metric,
-                embed_function=embed_function,
+        # Check distance metric
+        distance_metric = metadata.get("distance_metric", "cosine")
+        if distance_metric not in ["cosine", "euclidean"]:
+            raise ValueError(
+                "`distance_metric` must be one of 'cosine' or 'euclidean'."
             )
 
-        return query_fn
+        # Check embed model
+        embed_function = metadata.get("embed_function", None)
+        if embed_function is None:
+            raise ValueError(
+                "You must provide `embed_function` in metadata in order to "
+                "use the default query function."
+            )
+        return partial(
+            self.query_vector_collection,
+            sources=metadata["sources"],
+            chunk_strategy=chunk_strategy,
+            chunk_size=chunk_size,
+            chunk_overlap=chunk_overlap,
+            distance_metric=distance_metric,
+            embed_function=embed_function,
+        )
 
     @retry(wait=wait_random_exponential(min=1, max=60), stop=stop_after_attempt(6))
     def call_llm(self, prompt: str) -> str:
@@ -2188,6 +1947,11 @@ def evaluate_with_llm(self, text: str, query_function: Callable) -> bool:
     def validate_each_sentence(
         self, value: Any, query_function: Callable, metadata: Dict[str, Any]
     ) -> ValidationResult:
+        if nltk is None:
+            raise ImportError(
+                "`nltk` library is required for `provenance-v0` validator. "
+                "Please install it with `pip install nltk`."
+            )
         # Split the value into sentences using nltk sentence tokenizer.
         sentences = nltk.sent_tokenize(value)
 
@@ -2263,11 +2027,11 @@ def query_vector_collection(
         text: str,
         k: int,
         sources: List[str],
+        embed_function: Callable,
         chunk_strategy: str = "sentence",
         chunk_size: int = 5,
         chunk_overlap: int = 2,
         distance_metric: str = "cosine",
-        embed_function: Optional[Callable] = None,
     ) -> List[Tuple[str, float]]:
         chunks = [
             get_chunks_from_text(source, chunk_strategy, chunk_size, chunk_overlap)
@@ -2390,7 +2154,7 @@ def validate(self, value: Any, metadata: Dict) -> ValidationResult:
             # Check whether the value lies in a similar distribution as the prev_values
             # Get mean and std of prev_values
             prev_values = np.array(prev_values)
-            prev_mean = np.mean(prev_values)
+            prev_mean = np.mean(prev_values)  # type: ignore
             prev_std = np.std(prev_values)
 
             # Check whether the value lies outside specified stds of the mean
@@ -2424,10 +2188,12 @@ def validate(self, value: Any, metadata: Dict) -> ValidationResult:
             # Get average semantic similarity
             # Lesser the average semantic similarity, more similar the strings are
             avg_semantic_similarity = np.mean(
-                [
-                    self.get_semantic_similarity(value, prev_value, embed_function)
-                    for prev_value in prev_values
-                ]
+                np.array(
+                    [
+                        self.get_semantic_similarity(value, prev_value, embed_function)
+                        for prev_value in prev_values
+                    ]
+                )
             )
 
             # If average semantic similarity is above the threshold,
@@ -2517,11 +2283,19 @@ def get_unique_secrets(self, value: str) -> Tuple[Dict[str, Any], List[str]]:
 
         try:
             # Create a new secrets collection
-            secrets = detect_secrets.SecretsCollection()
+            from detect_secrets import settings
+            from detect_secrets.core.secrets_collection import SecretsCollection
+
+            secrets = SecretsCollection()
 
             # Scan the file for secrets
-            with detect_secrets.settings.default_settings():
+            with settings.default_settings():
                 secrets.scan_file(self.temp_file_name)
+        except ImportError:
+            raise ValueError(
+                "You must install detect-secrets in order to "
+                "use the DetectSecrets validator."
+            )
         except Exception as e:
             raise RuntimeError(
                 "Problems with creating a SecretsCollection or "
diff --git a/guardrails/vectordb/base.py b/guardrails/vectordb/base.py
index 8896f5bd5..6499f14ca 100644
--- a/guardrails/vectordb/base.py
+++ b/guardrails/vectordb/base.py
@@ -1,5 +1,5 @@
 from abc import ABC, abstractmethod
-from typing import Any, List
+from typing import Any, List, Optional
 
 from guardrails.embedding import EmbeddingBase
 
@@ -8,7 +8,7 @@
 class VectorDBBase(ABC):
     """Base class for vector databases."""
 
-    def __init__(self, embedder: EmbeddingBase, path: str = None) -> None:
+    def __init__(self, embedder: EmbeddingBase, path: Optional[str] = None) -> None:
         """Creates a new VectorDBBase.
 
         Args:
@@ -69,7 +69,7 @@ def similarity_search_with_threshold(
         vector = self._embedder.embed_query(text)
         return self.similarity_search_vector_with_threshold(vector, k, threshold)
 
-    def add_texts(self, texts: List[str], ids: List[Any] = None) -> None:
+    def add_texts(self, texts: List[str], ids: Optional[List[Any]] = None) -> None:
         """Adds a list of texts to the store.
 
         Args:
@@ -80,7 +80,7 @@ def add_texts(self, texts: List[str], ids: List[Any] = None) -> None:
         self.add_vectors(vectors)
 
     @abstractmethod
-    def save(self, path: str = None):
+    def save(self, path: Optional[str] = None):
         """Saves the vector database to the given path."""
         ...
 
diff --git a/guardrails/vectordb/faiss.py b/guardrails/vectordb/faiss.py
index 3f050f27f..7e5ce11d5 100644
--- a/guardrails/vectordb/faiss.py
+++ b/guardrails/vectordb/faiss.py
@@ -1,58 +1,60 @@
-from typing import List
+from typing import List, Optional
 
 from guardrails.embedding import EmbeddingBase
 from guardrails.vectordb.base import VectorDBBase
 
 try:
     import faiss
+    from faiss import Index
+
 except ImportError:
-    faiss = None
+    pass
+
+faiss_error = (
+    "`faiss` is required for using vectordb.faiss."
+    "Install it with `pip install faiss-cpu`."
+)
 
 
 class Faiss(VectorDBBase):
     def __init__(
-        self, index: "faiss.Index", embedder: EmbeddingBase, path: str = None
+        self, index: "Index", embedder: EmbeddingBase, path: Optional[str] = None
     ) -> None:
-        if faiss is None:
-            raise ImportError(
-                "`faiss` is required for using vectordb.faiss."
-                "Install it with `pip install faiss-cpu`."
-            )
+        try:
+            import faiss  # noqa: F401
+        except ImportError:
+            raise ImportError(faiss_error)
 
         super().__init__(embedder, path)
         self._index = index
 
     @classmethod
     def new_flat_l2_index(
-        cls, vector_dim: int, embedder: EmbeddingBase, path: str = None
+        cls, vector_dim: int, embedder: EmbeddingBase, path: Optional[str] = None
     ):
-        if faiss is None:
-            raise ImportError(
-                "`faiss` is required for using vectordb.faiss."
-                "Install it with `pip install faiss-cpu`."
-            )
+        try:
+            import faiss
+        except ImportError:
+            raise ImportError(faiss_error)
         return cls(faiss.IndexFlatL2(vector_dim), embedder, path)
 
     @classmethod
     def new_flat_ip_index(
-        cls, vector_dim: int, embedder: EmbeddingBase, path: str = None
+        cls, vector_dim: int, embedder: EmbeddingBase, path: Optional[str] = None
     ):
         if faiss is None:
-            raise ImportError(
-                "`faiss` is required for using vectordb.faiss."
-                "Install it with `pip install faiss-cpu`."
-            )
+            raise ImportError(faiss_error)
         return cls(faiss.IndexFlatIP(vector_dim), embedder, path)
 
     @classmethod
     def new_flat_l2_index_from_embedding(
-        cls, embedding: List[List[float]], embedder: EmbeddingBase, path: str = None
+        cls,
+        embedding: List[List[float]],
+        embedder: EmbeddingBase,
+        path: Optional[str] = None,
     ):
         if faiss is None:
-            raise ImportError(
-                "`faiss` is required for using vectordb.faiss."
-                "Install it with `pip install faiss-cpu`."
-            )
+            raise ImportError(faiss_error)
         store = cls(faiss.IndexFlatL2(len(embedding[0])), embedder, path)
         store.add_vectors(embedding)
         return store
@@ -60,22 +62,21 @@ def new_flat_l2_index_from_embedding(
     @classmethod
     def load(cls, path: str, embedder: EmbeddingBase):
         if faiss is None:
-            raise ImportError(
-                "`faiss` is required for using vectordb.faiss."
-                "Install it with `pip install faiss-cpu`."
-            )
+            raise ImportError(faiss_error)
 
         index = faiss.read_index(path)
         return cls(index, embedder, path)
 
-    def save(self, path: str = None):
+    def save(self, path: Optional[str] = None):
         write_path = path if path else self._path
         faiss.write_index(self._index, write_path)
 
     def similarity_search_vector(self, vector: List[float], k: int) -> List[int]:
         import numpy as np
 
-        _, scores = self._index.search(np.array([vector]), k)
+        # FIXME is this correct usage of `search`?
+        #  Arguments missing for parameters "k", "distances", "labels"
+        _, scores = self._index.search(np.array([vector]), k)  # type: ignore
         return scores[0].tolist()
 
     def similarity_search_vector_with_threshold(
@@ -84,7 +85,11 @@ def similarity_search_vector_with_threshold(
         import numpy as np
 
         # Call faiss range search and get all the vectors with a score >= threshold
-        _, dist, indexes = self._index.range_search(np.array([vector]), threshold)
+        # FIXME is this correct usage of `range_search`?
+        #  Arguments missing for parameters "radius", "result"
+        _, dist, indexes = self._index.range_search(
+            np.array([vector]), threshold
+        )  # type: ignore
 
         if len(indexes) == 0:
             return []
@@ -96,7 +101,9 @@ def similarity_search_vector_with_threshold(
     def add_vectors(self, vectors: List[List[float]]) -> None:
         import numpy as np
 
-        self._index.add(np.array(vectors))
+        # FIXME is this correct usage of `add`?
+        #  Arguments missing for parameters "x"
+        self._index.add(np.array(vectors))  # type: ignore
 
     def last_index(self) -> int:
         return self._index.ntotal
diff --git a/mkdocs.yml b/mkdocs.yml
index 4295740bd..a81f35c71 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -175,6 +175,9 @@ plugins:
 
 
 extra:
+  analytics:
+    provider: google
+    property: G-CXQ3SCNV24
   version:
     provider: mike
   social:
@@ -184,3 +187,7 @@ extra:
       link: https://github.com/guardrails-ai/guardrails
     - icon: fontawesome/brands/discord
       link: https://discord.gg/Jsey3mX98B
+
+extra_javascript:
+  - javascripts/pipedrive.js
+  - javascripts/posthog.js
diff --git a/setup.py b/setup.py
index a46b5cb41..9f2cd0ccf 100644
--- a/setup.py
+++ b/setup.py
@@ -23,7 +23,7 @@
 URL = "https://github.com/guardrails-ai/guardrails"
 EMAIL = "shreya.rajpal@gmail.com"
 AUTHOR = "Shreya Rajpal"
-REQUIRES_PYTHON = ">=3.7.0"
+REQUIRES_PYTHON = ">=3.8.0"
 VERSION = main_ns["__version__"]
 
 # What packages are required for this module to be executed?
@@ -39,6 +39,7 @@
     "tenacity>=8.1.0",
     "pytest",
     "regex",
+    "typing-extensions",
     "rstr",
 ]
 
@@ -69,6 +70,8 @@
     "pypdfium2",
     "pytest",
     "pytest-asyncio",
+    "pyright",
+    "lxml-stubs",
     *SUMMARY_REQUIREMENTS,
     *PROFANITY_REQUIREMENTS,
     *SQL_REQUIREMENTS,
@@ -182,7 +185,7 @@ def run(self):
         # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers
         "Programming Language :: Python",
         "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.6",
+        "Programming Language :: Python :: 3.8",
         "Topic :: Scientific/Engineering :: Artificial Intelligence",
     ],
     # $ setup.py publish support.


Test patch:
diff --git a/tests/integration_tests/applications/test_text2sql.py b/tests/integration_tests/applications/test_text2sql.py
index 0af39ae6f..60de90394 100644
--- a/tests/integration_tests/applications/test_text2sql.py
+++ b/tests/integration_tests/applications/test_text2sql.py
@@ -1,6 +1,7 @@
 import json
 import os
 
+import openai
 import pytest
 
 from guardrails.applications.text2sql import Text2Sql
@@ -26,7 +27,7 @@ def test_text2sql_with_examples(conn_str: str, schema_path: str, examples: str,
     # Mock the call to the OpenAI API.
     mocker.patch(
         "guardrails.embedding.OpenAIEmbedding._get_embedding",
-        new=lambda *args, **kwargs: [0.1] * 1536,
+        new=lambda *args, **kwargs: [[0.1] * 1536],
     )
 
     if examples is not None:
@@ -35,3 +36,9 @@ def test_text2sql_with_examples(conn_str: str, schema_path: str, examples: str,
 
     # This should not raise an exception.
     Text2Sql(conn_str, schema_file=schema_path, examples=examples)
+
+
+def test_text2sql_with_coro():
+    s = Text2Sql("sqlite://", llm_api=openai.Completion.acreate)
+    with pytest.raises(ValueError):
+        s("")
diff --git a/tests/integration_tests/mock_llm_outputs.py b/tests/integration_tests/mock_llm_outputs.py
index ea499cfdc..11e3d2bbf 100644
--- a/tests/integration_tests/mock_llm_outputs.py
+++ b/tests/integration_tests/mock_llm_outputs.py
@@ -25,7 +25,9 @@ def _invoke_llm(self, prompt, *args, **kwargs):
             entity_extraction.COMPILED_PROMPT_SKELETON_REASK_2: entity_extraction.LLM_OUTPUT_SKELETON_REASK_2,  # noqa: E501
             pydantic.COMPILED_PROMPT: pydantic.LLM_OUTPUT,
             pydantic.COMPILED_PROMPT_REASK_1: pydantic.LLM_OUTPUT_REASK_1,
+            pydantic.COMPILED_PROMPT_FULL_REASK_1: pydantic.LLM_OUTPUT_FULL_REASK_1,
             pydantic.COMPILED_PROMPT_REASK_2: pydantic.LLM_OUTPUT_REASK_2,
+            pydantic.COMPILED_PROMPT_FULL_REASK_2: pydantic.LLM_OUTPUT_FULL_REASK_2,
             string.COMPILED_PROMPT: string.LLM_OUTPUT,
             string.COMPILED_PROMPT_REASK: string.LLM_OUTPUT_REASK,
             string.COMPILED_LIST_PROMPT: string.LIST_LLM_OUTPUT,
@@ -88,6 +90,18 @@ def _invoke_llm(
                 pydantic.MSG_COMPILED_PROMPT_REASK,
                 pydantic.MSG_COMPILED_INSTRUCTIONS_REASK,
             ): pydantic.MSG_HISTORY_LLM_OUTPUT_CORRECT,
+            (
+                pydantic.COMPILED_PROMPT_CHAT,
+                pydantic.COMPILED_INSTRUCTIONS_CHAT,
+            ): pydantic.LLM_OUTPUT,
+            (
+                pydantic.COMPILED_PROMPT_FULL_REASK_1,
+                pydantic.COMPILED_INSTRUCTIONS_CHAT,
+            ): pydantic.LLM_OUTPUT_FULL_REASK_1,
+            (
+                pydantic.COMPILED_PROMPT_FULL_REASK_2,
+                pydantic.COMPILED_INSTRUCTIONS_CHAT,
+            ): pydantic.LLM_OUTPUT_FULL_REASK_2,
             (
                 string.PARSE_COMPILED_PROMPT_REASK,
                 string.MSG_COMPILED_INSTRUCTIONS_REASK,
diff --git a/tests/integration_tests/test_assets/pydantic/__init__.py b/tests/integration_tests/test_assets/pydantic/__init__.py
index 7f990f8e6..d41390e71 100644
--- a/tests/integration_tests/test_assets/pydantic/__init__.py
+++ b/tests/integration_tests/test_assets/pydantic/__init__.py
@@ -23,12 +23,20 @@
 )
 
 COMPILED_PROMPT = reader("compiled_prompt.txt")
+COMPILED_PROMPT_CHAT = reader("compiled_prompt_chat.txt")
+COMPILED_INSTRUCTIONS_CHAT = reader("compiled_instructions_chat.txt")
 COMPILED_PROMPT_REASK_1 = reader("compiled_prompt_reask_1.txt")
+COMPILED_PROMPT_FULL_REASK_1 = reader("compiled_prompt_full_reask_1.txt")
+COMPILED_INSTRUCTIONS_REASK_1 = reader("compiled_instructions_reask_1.txt")
 COMPILED_PROMPT_REASK_2 = reader("compiled_prompt_reask_2.txt")
+COMPILED_PROMPT_FULL_REASK_2 = reader("compiled_prompt_full_reask_2.txt")
+COMPILED_INSTRUCTIONS_REASK_2 = reader("compiled_instructions_reask_2.txt")
 
 LLM_OUTPUT = reader("llm_output.txt")
 LLM_OUTPUT_REASK_1 = reader("llm_output_reask_1.txt")
+LLM_OUTPUT_FULL_REASK_1 = reader("llm_output_full_reask_1.txt")
 LLM_OUTPUT_REASK_2 = reader("llm_output_reask_2.txt")
+LLM_OUTPUT_FULL_REASK_2 = reader("llm_output_full_reask_2.txt")
 
 RAIL_SPEC_WITH_REASK = reader("reask.rail")
 
diff --git a/tests/integration_tests/test_assets/pydantic/compiled_instructions_chat.txt b/tests/integration_tests/test_assets/pydantic/compiled_instructions_chat.txt
new file mode 100644
index 000000000..04f827a07
--- /dev/null
+++ b/tests/integration_tests/test_assets/pydantic/compiled_instructions_chat.txt
@@ -0,0 +1,1 @@
+You are a helpful assistant, able to express yourself purely through JSON, strictly and precisely adhering to the provided XML schemas.
\ No newline at end of file
diff --git a/tests/integration_tests/test_assets/pydantic/compiled_instructions_reask_1.txt b/tests/integration_tests/test_assets/pydantic/compiled_instructions_reask_1.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/integration_tests/test_assets/pydantic/compiled_instructions_reask_2.txt b/tests/integration_tests/test_assets/pydantic/compiled_instructions_reask_2.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/integration_tests/test_assets/pydantic/compiled_prompt_chat.txt b/tests/integration_tests/test_assets/pydantic/compiled_prompt_chat.txt
new file mode 100644
index 000000000..e8d178450
--- /dev/null
+++ b/tests/integration_tests/test_assets/pydantic/compiled_prompt_chat.txt
@@ -0,0 +1,37 @@
+Generate data for possible users in accordance with the specification below.
+
+
+Given below is XML that describes the information to extract from this document and the tags to extract it into.
+
+
+<output>
+    <list name="people">
+        <object>
+            <string name="name"/>
+            <integer name="age" format="age_must_be_between_0_and_150"/>
+            <string name="zip_code" format="zip_code_must_be_numeric; zip_code_in_california"/>
+        </object>
+    </list>
+</output>
+
+
+
+Given below is XML that describes the information to extract from this document and the tags to extract it into.
+
+<output>
+    <list name="people">
+        <object>
+            <string name="name"/>
+            <integer name="age" format="age_must_be_between_0_and_150"/>
+            <string name="zip_code" format="zip_code_must_be_numeric; zip_code_in_california"/>
+        </object>
+    </list>
+</output>
+
+
+ONLY return a valid JSON object (no other text is necessary), where the key of the field in JSON is the `name` attribute of the corresponding XML, and the value is of the type specified by the corresponding XML's tag. The JSON MUST conform to the XML format, including any types and format requests e.g. requests for lists, objects and specific types. Be correct and concise.
+
+Here are examples of simple (XML, JSON) pairs that show the expected behavior:
+- `<string name='foo' format='two-words lower-case' />` => `{'foo': 'example one'}`
+- `<list name='bar'><string format='upper-case' /></list>` => `{"bar": ['STRING ONE', 'STRING TWO', etc.]}`
+- `<object name='baz'><string name="foo" format="capitalize two-words" /><integer name="index" format="1-indexed" /></object>` => `{'baz': {'foo': 'Some String', 'index': 1}}`
diff --git a/tests/integration_tests/test_assets/pydantic/compiled_prompt_full_reask_1.txt b/tests/integration_tests/test_assets/pydantic/compiled_prompt_full_reask_1.txt
new file mode 100644
index 000000000..a69b080ee
--- /dev/null
+++ b/tests/integration_tests/test_assets/pydantic/compiled_prompt_full_reask_1.txt
@@ -0,0 +1,44 @@
+
+I was given the following JSON response, which had problems due to incorrect values.
+
+{
+  "people": [
+    {
+      "name": "John Doe",
+      "age": 28,
+      "zip_code": {
+        "incorrect_value": "90210",
+        "error_messages": [
+          "Zip code must not be Beverly Hills."
+        ]
+      }
+    },
+    {
+      "name": "Jane Doe",
+      "age": 32,
+      "zip_code": "94103"
+    },
+    {
+      "name": "James Smith",
+      "age": 40,
+      "zip_code": "92101"
+    }
+  ]
+}
+
+Help me correct the incorrect values based on the given error messages.
+
+Given below is XML that describes the information to extract from this document and the tags to extract it into.
+
+<output>
+    <list name="people">
+        <object>
+            <string name="name"/>
+            <integer name="age" format="age_must_be_between_0_and_150"/>
+            <string name="zip_code" format="zip_code_must_be_numeric; zip_code_in_california"/>
+        </object>
+    </list>
+</output>
+
+
+ONLY return a valid JSON object (no other text is necessary), where the key of the field in JSON is the `name` attribute of the corresponding XML, and the value is of the type specified by the corresponding XML's tag. The JSON MUST conform to the XML format, including any types and format requests e.g. requests for lists, objects and specific types. Be correct and concise. If you are unsure anywhere, enter `null`.
diff --git a/tests/integration_tests/test_assets/pydantic/compiled_prompt_full_reask_2.txt b/tests/integration_tests/test_assets/pydantic/compiled_prompt_full_reask_2.txt
new file mode 100644
index 000000000..a8b1ea33d
--- /dev/null
+++ b/tests/integration_tests/test_assets/pydantic/compiled_prompt_full_reask_2.txt
@@ -0,0 +1,45 @@
+
+I was given the following JSON response, which had problems due to incorrect values.
+
+{
+  "people": [
+    {
+      "name": "John Doe",
+      "age": 28,
+      "zip_code": {
+        "incorrect_value": "None",
+        "error_messages": [
+          "Zip code must be numeric.",
+          "Zip code must be in California, and start with 9."
+        ]
+      }
+    },
+    {
+      "name": "Jane Doe",
+      "age": 32,
+      "zip_code": "94103"
+    },
+    {
+      "name": "James Smith",
+      "age": 40,
+      "zip_code": "92101"
+    }
+  ]
+}
+
+Help me correct the incorrect values based on the given error messages.
+
+Given below is XML that describes the information to extract from this document and the tags to extract it into.
+
+<output>
+    <list name="people">
+        <object>
+            <string name="name"/>
+            <integer name="age" format="age_must_be_between_0_and_150"/>
+            <string name="zip_code" format="zip_code_must_be_numeric; zip_code_in_california"/>
+        </object>
+    </list>
+</output>
+
+
+ONLY return a valid JSON object (no other text is necessary), where the key of the field in JSON is the `name` attribute of the corresponding XML, and the value is of the type specified by the corresponding XML's tag. The JSON MUST conform to the XML format, including any types and format requests e.g. requests for lists, objects and specific types. Be correct and concise. If you are unsure anywhere, enter `null`.
diff --git a/tests/integration_tests/test_assets/pydantic/llm_output_full_reask_1.txt b/tests/integration_tests/test_assets/pydantic/llm_output_full_reask_1.txt
new file mode 100644
index 000000000..d5af4fada
--- /dev/null
+++ b/tests/integration_tests/test_assets/pydantic/llm_output_full_reask_1.txt
@@ -0,0 +1,19 @@
+{
+  "people": [
+    {
+      "name": "John Doe",
+      "age": 28,
+      "zip_code": "None"
+    },
+    {
+      "name": "Jane Doe",
+      "age": 32,
+      "zip_code": "94103"
+    },
+    {
+      "name": "James Smith",
+      "age": 40,
+      "zip_code": "92101"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/tests/integration_tests/test_assets/pydantic/llm_output_full_reask_2.txt b/tests/integration_tests/test_assets/pydantic/llm_output_full_reask_2.txt
new file mode 100644
index 000000000..d5af4fada
--- /dev/null
+++ b/tests/integration_tests/test_assets/pydantic/llm_output_full_reask_2.txt
@@ -0,0 +1,19 @@
+{
+  "people": [
+    {
+      "name": "John Doe",
+      "age": 28,
+      "zip_code": "None"
+    },
+    {
+      "name": "Jane Doe",
+      "age": 32,
+      "zip_code": "94103"
+    },
+    {
+      "name": "James Smith",
+      "age": 40,
+      "zip_code": "92101"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/tests/integration_tests/test_assets/pydantic/parsing_reask.py b/tests/integration_tests/test_assets/pydantic/parsing_reask.py
index 938f26c70..03a8247bd 100644
--- a/tests/integration_tests/test_assets/pydantic/parsing_reask.py
+++ b/tests/integration_tests/test_assets/pydantic/parsing_reask.py
@@ -24,7 +24,7 @@
 
 compiled_prompt = """\n\nHuman:\nGiven the following resume, answer the following questions. If the answer doesn\'t exist in the resume, enter `null`.\n\nJoe Smith – 1234 5678 / joe@example.com  PRIVATE & CONFIDENTIAL\n 1 Joe Smith\nLorem ipsum dolor sit amet, consectetur adipiscing elit,\nsed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris\nnisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in\nreprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\nExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia\ndeserunt mollit anim id est laborum.\n\nExtract information from this resume and return a JSON that follows the correct schema.\n\n\nGiven below is XML that describes the information to extract from this document and the tags to extract it into.\n\n<output>\n    <string name="name" description="What is the candidate name?"/>\n    <string name="contact_number" description="What is the candidate contact number?"/>\n    <string name="contact_email" description="What is the candidate email address?"/>\n</output>\n\n\nONLY return a valid JSON object (no other text is necessary), where the key of the field in JSON is the `name` attribute of the corresponding XML, and the value is of the type specified by the corresponding XML\'s tag. The JSON MUST conform to the XML format, including any types and format requests e.g. requests for lists, objects and specific types. Be correct and concise. If you are unsure anywhere, enter `null`.\n\nHere are examples of simple (XML, JSON) pairs that show the expected behavior:\n- `<string name=\'foo\' format=\'two-words lower-case\' />` => `{\'foo\': \'example one\'}`\n- `<list name=\'bar\'><string format=\'upper-case\' /></list>` => `{"bar": [\'STRING ONE\', \'STRING TWO\', etc.]}`\n- `<object name=\'baz\'><string name="foo" format="capitalize two-words" /><integer name="index" format="1-indexed" /></object>` => `{\'baz\': {\'foo\': \'Some String\', \'index\': 1}}`\n\n\n\n\nAssistant:\n"""  # noqa
 
-compiled_reask = """\nI was given the following response, which was not parseable as JSON.\n\n{\n  "incorrect_value": "Here is the JSON containing the requested information extracted from the resume:\\n\\n```\\n  name: Joe Smith\\n  contact_number: 1234 5678\\n  contact_email: joe@example.com\\n```",\n  "error_messages": [\n    "Output is not parseable as JSON"\n  ]\n}\n\nHelp me correct this by making it valid JSON.\n\nGiven below is XML that describes the information to extract from this document and the tags to extract it into.\n\n<output>\n    <string name="name" description="What is the candidate name?"/>\n    <string name="contact_number" description="What is the candidate contact number?"/>\n    <string name="contact_email" description="What is the candidate email address?"/>\n</output>\n\n\nONLY return a valid JSON object (no other text is necessary), where the key of the field in JSON is the `name` attribute of the corresponding XML, and the value is of the type specified by the corresponding XML\'s tag. The JSON MUST conform to the XML format, including any types and format requests e.g. requests for lists, objects and specific types. Be correct and concise. If you are unsure anywhere, enter `null`.\n"""  # noqa
+compiled_reask = """\nI was given the following response, which was not parseable as JSON.\n\n"Here is the JSON containing the requested information extracted from the resume:\\n\\n```\\n{\\n  \\"name\\": \\"Joe Smith\\",\\n  \\"contact_number\\": \\"1234 5678\\",\\n  \\"contact_email\\": \\"joe@example.com\\"\\n```"\n\nHelp me correct this by making it valid JSON.\n\nGiven below is XML that describes the information to extract from this document and the tags to extract it into.\n\n<output>\n    <string name="name" description="What is the candidate name?"/>\n    <string name="contact_number" description="What is the candidate contact number?"/>\n    <string name="contact_email" description="What is the candidate email address?"/>\n</output>\n\n\nONLY return a valid JSON object (no other text is necessary), where the key of the field in JSON is the `name` attribute of the corresponding XML, and the value is of the type specified by the corresponding XML\'s tag. The JSON MUST conform to the XML format, including any types and format requests e.g. requests for lists, objects and specific types. Be correct and concise. If you are unsure anywhere, enter `null`.\n"""  # noqa
 
 
 class PersonalDetails(BaseModel):
@@ -48,9 +48,10 @@ class PersonalDetails(BaseModel):
 unparseable_llm_response = """Here is the JSON containing the requested information extracted from the resume:
 
 ```
-  name: Joe Smith
-  contact_number: 1234 5678
-  contact_email: joe@example.com
+{
+  "name": "Joe Smith",
+  "contact_number": "1234 5678",
+  "contact_email": "joe@example.com"
 ```"""  # noqa
 
 expected_output = {
diff --git a/tests/integration_tests/test_async.py b/tests/integration_tests/test_async.py
index 4360c82c1..98d6be42f 100644
--- a/tests/integration_tests/test_async.py
+++ b/tests/integration_tests/test_async.py
@@ -223,3 +223,43 @@ async def test_rail_spec_output_parse(rail_spec, llm_output, validated_output):
         llm_api=openai.Completion.acreate,
     )
     assert output == validated_output
+
+
+@pytest.fixture
+def string_rail_spec():
+    return """
+<rail version="0.1">
+<output
+  type="string"
+  format="two-words"
+  on-fail-two-words="fix"
+/>
+<prompt>
+Hi please make me a string
+</prompt>
+</rail>
+"""
+
+
+@pytest.fixture
+def string_llm_output():
+    return "string output yes"
+
+
+@pytest.fixture
+def validated_string_output():
+    return "string output"
+
+
+@pytest.mark.asyncio
+async def test_string_rail_spec_output_parse(
+    string_rail_spec, string_llm_output, validated_string_output
+):
+    """Test that the string_rail_spec fixture is working."""
+    guard = gd.Guard.from_rail_string(string_rail_spec)
+    output = await guard.parse(
+        string_llm_output,
+        llm_api=openai.Completion.acreate,
+        num_reasks=0,
+    )
+    assert output == validated_string_output
diff --git a/tests/integration_tests/test_json_utils.py b/tests/integration_tests/test_json_utils.py
index 3509efca7..cac3a8a46 100644
--- a/tests/integration_tests/test_json_utils.py
+++ b/tests/integration_tests/test_json_utils.py
@@ -1,5 +1,6 @@
 import pytest
 
+from guardrails.datatypes import Integer
 from guardrails.utils.json_utils import (
     ChoicePlaceholder,
     DictPlaceholder,
@@ -9,16 +10,16 @@
 
 
 @pytest.mark.parametrize(
-    "optional,type_string,value,coerce_types,expected_value",
+    "optional,datatype,value,coerce_types,expected_value",
     [
-        (False, "integer", None, True, ValuePlaceholder.VerificationFailed),
-        (False, "integer", None, False, ValuePlaceholder.VerificationFailed),
+        (False, Integer, None, True, ValuePlaceholder.VerificationFailed),
+        (False, Integer, None, False, ValuePlaceholder.VerificationFailed),
     ],
 )
 def test_value_placeholder_verify(
-    optional, type_string, value, coerce_types, expected_value
+    optional, datatype, value, coerce_types, expected_value
 ):
-    ph = ValuePlaceholder(optional, type_string)
+    ph = ValuePlaceholder(optional, datatype)
 
     verified_type = ph.verify(value, False, coerce_types)
 
@@ -32,7 +33,7 @@ def test_value_placeholder_verify(
         (False, {}, None, False, False),
         (
             False,
-            {"child": ValuePlaceholder(False, "integer")},
+            {"child": ValuePlaceholder(False, Integer)},
             {"child": None},
             False,
             False,
@@ -54,7 +55,7 @@ def test_dict_placeholder_verify(
     [
         (True, None, None, True, True),
         (False, None, None, False, False),
-        (False, ValuePlaceholder(False, "integer"), [None], False, False),
+        (False, ValuePlaceholder(False, Integer), [None], False, False),
     ],
 )
 def test_list_placeholder_verify(
diff --git a/tests/integration_tests/test_pydantic.py b/tests/integration_tests/test_pydantic.py
index 789a92cef..2109a4f51 100644
--- a/tests/integration_tests/test_pydantic.py
+++ b/tests/integration_tests/test_pydantic.py
@@ -2,7 +2,7 @@
 
 import guardrails as gd
 
-from .mock_llm_outputs import MockOpenAICallable, pydantic
+from .mock_llm_outputs import MockOpenAICallable, MockOpenAIChatCallable, pydantic
 from .test_assets.pydantic import VALIDATED_RESPONSE_REASK_PROMPT, ListOfPeople
 
 
@@ -42,3 +42,52 @@ def test_pydantic_with_reask(mocker):
     assert guard_history[2].prompt == gd.Prompt(pydantic.COMPILED_PROMPT_REASK_2)
     assert guard_history[2].output == pydantic.LLM_OUTPUT_REASK_2
     assert guard_history[2].validated_output == pydantic.VALIDATED_OUTPUT_REASK_3
+
+
+def test_pydantic_with_full_schema_reask(mocker):
+    """Test that the entity extraction works with re-asking."""
+    mocker.patch(
+        "guardrails.llm_providers.OpenAIChatCallable", new=MockOpenAIChatCallable
+    )
+
+    guard = gd.Guard.from_pydantic(ListOfPeople, prompt=VALIDATED_RESPONSE_REASK_PROMPT)
+    _, final_output = guard(
+        openai.ChatCompletion.create,
+        model="gpt-3.5-turbo",
+        max_tokens=512,
+        temperature=0.5,
+        num_reasks=2,
+        full_schema_reask=True,
+    )
+
+    # Assertions are made on the guard state object.
+    assert final_output == pydantic.VALIDATED_OUTPUT_REASK_3
+
+    guard_history = guard.guard_state.most_recent_call.history
+
+    # Check that the guard state object has the correct number of re-asks.
+    assert len(guard_history) == 3
+
+    # For orginal prompt and output
+    assert guard_history[0].prompt == gd.Prompt(pydantic.COMPILED_PROMPT_CHAT)
+    assert guard_history[0].instructions == gd.Instructions(
+        pydantic.COMPILED_INSTRUCTIONS_CHAT
+    )
+    assert guard_history[0].output == pydantic.LLM_OUTPUT
+    assert guard_history[0].validated_output == pydantic.VALIDATED_OUTPUT_REASK_1
+
+    # For re-asked prompt and output
+    assert guard_history[1].prompt == gd.Prompt(pydantic.COMPILED_PROMPT_FULL_REASK_1)
+    assert guard_history[1].instructions == gd.Instructions(
+        pydantic.COMPILED_INSTRUCTIONS_CHAT
+    )
+    assert guard_history[1].output == pydantic.LLM_OUTPUT_FULL_REASK_1
+    assert guard_history[1].validated_output == pydantic.VALIDATED_OUTPUT_REASK_2
+
+    # For re-asked prompt #2 and output #2
+    assert guard_history[2].prompt == gd.Prompt(pydantic.COMPILED_PROMPT_FULL_REASK_2)
+    assert guard_history[2].instructions == gd.Instructions(
+        pydantic.COMPILED_INSTRUCTIONS_CHAT
+    )
+    assert guard_history[2].output == pydantic.LLM_OUTPUT_FULL_REASK_2
+    assert guard_history[2].validated_output == pydantic.VALIDATED_OUTPUT_REASK_3
diff --git a/tests/integration_tests/test_run.py b/tests/integration_tests/test_run.py
index 57ba7199c..354a7d8b8 100644
--- a/tests/integration_tests/test_run.py
+++ b/tests/integration_tests/test_run.py
@@ -12,13 +12,15 @@
 from .mock_llm_outputs import MockAsyncOpenAICallable, MockOpenAICallable
 from .test_assets import string
 
-PROMPT = gd.Prompt(string.COMPILED_PROMPT)
+PROMPT = gd.Prompt(source=string.COMPILED_PROMPT)
 INSTRUCTIONS = gd.Instructions(
     """ You are a helpful assistant, and you are helping me
      come up with a name for a pizza. ${gr.complete_string_suffix}"""
 )
-OUTPUT_SCHEMA = StringSchema(
-    root=ET.fromstring(
+
+
+OUTPUT_SCHEMA = StringSchema.from_element(
+    ET.fromstring(
         """<output
     type="string"
     description="Name for the pizza"
@@ -26,6 +28,7 @@
     on-fail-two-words="reask" />"""
     )
 )
+
 OUTPUT = "Tomato Cheese Pizza"
 
 
@@ -39,6 +42,7 @@ def runner_instance(is_sync: bool):
             input_schema=None,
             output_schema=OUTPUT_SCHEMA,
             guard_state={},
+            num_reasks=0,
         )
     else:
         return AsyncRunner(
@@ -49,6 +53,7 @@ def runner_instance(is_sync: bool):
             input_schema=None,
             output_schema=OUTPUT_SCHEMA,
             guard_state={},
+            num_reasks=0,
         )
 
 
@@ -57,7 +62,6 @@ def runner_instance(is_sync: bool):
 )
 @pytest.mark.asyncio
 async def test_sync_async_call_equivalence(mocker):
-
     mocker.patch(
         "guardrails.llm_providers.AsyncOpenAICallable",
         new=MockAsyncOpenAICallable,
@@ -76,12 +80,11 @@ async def test_sync_async_call_equivalence(mocker):
 
     # Call the 'async_call' method asynchronously
     result_async = await runner_instance(False).async_call(
-        1,
-        INSTRUCTIONS,
-        PROMPT,
-        None,
-        AsyncOpenAICallable(**{"temperature": 0}),
-        "Tomato Cheese Pizza",
+        index=1,
+        instructions=INSTRUCTIONS,
+        prompt=PROMPT,
+        api=AsyncOpenAICallable(**{"temperature": 0}),
+        output="Tomato Cheese Pizza",
     )
 
     assert result_sync.output == result_async.output
@@ -89,7 +92,6 @@ async def test_sync_async_call_equivalence(mocker):
 
 @pytest.mark.asyncio
 async def test_sync_async_validate_equivalence(mocker):
-
     mocker.patch(
         "guardrails.llm_providers.AsyncOpenAICallable",
         new=MockAsyncOpenAICallable,
@@ -113,7 +115,6 @@ async def test_sync_async_validate_equivalence(mocker):
 
 @pytest.mark.asyncio
 async def test_sync_async_step_equivalence(mocker):
-
     mocker.patch(
         "guardrails.llm_providers.AsyncOpenAICallable",
         new=MockAsyncOpenAICallable,
diff --git a/tests/integration_tests/test_schema_to_prompt.py b/tests/integration_tests/test_schema_to_prompt.py
index 62efa4f81..5568ee80f 100644
--- a/tests/integration_tests/test_schema_to_prompt.py
+++ b/tests/integration_tests/test_schema_to_prompt.py
@@ -44,7 +44,7 @@ def test_choice_schema():
     guard = Guard.from_rail_string(rail_spec)
     schema_2_prompt = guard.output_schema.transpile()
     expected_schema_2_prompt = """<output>
-    <choice name="action">
+    <choice name="action" discriminator="discriminator">
         <case name="fight">
             <string name="fight_move" format="valid-choices: choices=['punch', 'kick', 'headbutt']"/>
         </case>
diff --git a/tests/integration_tests/test_validators.py b/tests/integration_tests/test_validators.py
index aed8f97bb..0e41f8bf3 100644
--- a/tests/integration_tests/test_validators.py
+++ b/tests/integration_tests/test_validators.py
@@ -41,7 +41,7 @@ def embed_function(text: str):
 
     # Check types remain intact
     output_schema: StringSchema = guard.rail.output_schema
-    data_type: DataType = getattr(output_schema._schema, "string")
+    data_type: DataType = output_schema.root_datatype
     validators = data_type.format_attr.validators
     validator: SimilarToList = validators[0]
 
diff --git a/tests/unit_tests/mocks/__init__.py b/tests/unit_tests/mocks/__init__.py
index 4b8683866..0e0cfed72 100644
--- a/tests/unit_tests/mocks/__init__.py
+++ b/tests/unit_tests/mocks/__init__.py
@@ -2,13 +2,11 @@
 from .mock_custom_llm import MockAsyncCustomLlm, MockCustomLlm
 from .mock_loop import MockLoop
 from .mock_sequential_validator_service import MockSequentialValidatorService
-from .mock_validator import MockValidator
 
 __all__ = [
     "MockAsyncValidatorService",
     "MockSequentialValidatorService",
     "MockLoop",
-    "MockValidator",
     "MockCustomLlm",
     "MockAsyncCustomLlm",
 ]
diff --git a/tests/unit_tests/mocks/mock_validator.py b/tests/unit_tests/mocks/mock_validator.py
index ab103bc27..5e051992f 100644
--- a/tests/unit_tests/mocks/mock_validator.py
+++ b/tests/unit_tests/mocks/mock_validator.py
@@ -4,21 +4,12 @@
 from guardrails.validators import FailResult, PassResult, ValidationResult
 
 
-class MockValidator(Validator):
-    def __init__(
-        self,
-        name: str,
-        on_fail: Union[str, Callable] = None,
-        should_pass: bool = True,
-        return_value: Any = None,
-    ):
-        register_validator(name=name, data_type=["string"])(self)
-        super().__init__(on_fail=on_fail)
-        self.name = name
-        self.on_fail = on_fail
-        self.should_pass = should_pass
-        self.return_value = return_value
-
+def create_mock_validator(
+    name: str,
+    on_fail: Union[str, Callable] = None,
+    should_pass: bool = True,
+    return_value: Any = None,
+):
     def validate(self, value: Any, metadata: Dict[str, Any]) -> ValidationResult:
         if self.should_pass:
             return self.return_value if self.return_value is not None else PassResult()
@@ -26,3 +17,17 @@ def validate(self, value: Any, metadata: Dict[str, Any]) -> ValidationResult:
             return FailResult(
                 error_message="Value is not valid.",
             )
+
+    validator_type = type(
+        name,
+        (Validator,),
+        {
+            "validate": validate,
+            "name": name,
+            "on_fail": on_fail,
+            "should_pass": should_pass,
+            "return_value": return_value,
+        },
+    )
+    register_validator(name=name, data_type=["string"])(validator_type)
+    return validator_type
diff --git a/tests/unit_tests/test_async_validator_service.py b/tests/unit_tests/test_async_validator_service.py
index d4f9faf9d..5283e96c0 100644
--- a/tests/unit_tests/test_async_validator_service.py
+++ b/tests/unit_tests/test_async_validator_service.py
@@ -7,7 +7,8 @@
 from guardrails.validator_service import AsyncValidatorService
 from guardrails.validators import PassResult
 
-from .mocks import MockLoop, MockValidator
+from .mocks import MockLoop
+from .mocks.mock_validator import create_mock_validator
 
 empty_field_validation = FieldValidation(
     key="mock-key", value="mock-value", validators=[], children=[]
@@ -168,9 +169,12 @@ async def mock_async_validate(v, md, *args):
 @pytest.mark.asyncio
 async def test_run_validators(mocker):
     group_validators_mock = mocker.patch.object(avs, "group_validators")
-    fix_validator = MockValidator("fix_validator", "fix")
-    noop_validator_1 = MockValidator("noop_validator_1")
-    noop_validator_2 = MockValidator("noop_validator_2")
+    fix_validator_type = create_mock_validator("fix_validator", "fix")
+    fix_validator = fix_validator_type()
+    noop_validator_type = create_mock_validator("noop_validator")
+    noop_validator_1 = noop_validator_type()
+    noop_validator_type = create_mock_validator("noop_validator")
+    noop_validator_2 = noop_validator_type()
     noop_validator_2.run_in_separate_process = True
     group_validators_mock.return_value = [
         ("fix", [fix_validator]),
@@ -232,7 +236,8 @@ async def mock_gather(*args):
 @pytest.mark.asyncio
 async def test_run_validators_with_override(mocker):
     group_validators_mock = mocker.patch.object(avs, "group_validators")
-    override_validator = MockValidator("override")
+    override_validator_type = create_mock_validator("override")
+    override_validator = override_validator_type()
     override_validator.override_value_on_pass = True
 
     group_validators_mock.return_value = [("exception", [override_validator])]
diff --git a/tests/unit_tests/test_guard.py b/tests/unit_tests/test_guard.py
index 7d9b3b653..2f6ee54a8 100644
--- a/tests/unit_tests/test_guard.py
+++ b/tests/unit_tests/test_guard.py
@@ -79,13 +79,11 @@ def validate(self, value, metadata):
 async def test_required_metadata(spec, metadata):
     guard = guardrails.Guard.from_rail_string(spec)
 
-    missing_keys = verify_metadata_requirements(
-        {}, guard.output_schema.to_dict().values()
-    )
+    missing_keys = verify_metadata_requirements({}, guard.output_schema.root_datatype)
     assert set(missing_keys) == set(metadata)
 
     not_missing_keys = verify_metadata_requirements(
-        metadata, guard.output_schema.to_dict().values()
+        metadata, guard.output_schema.root_datatype
     )
     assert not_missing_keys == []
 
diff --git a/tests/unit_tests/test_llm_providers.py b/tests/unit_tests/test_llm_providers.py
index 969d03edd..5440b9bd2 100644
--- a/tests/unit_tests/test_llm_providers.py
+++ b/tests/unit_tests/test_llm_providers.py
@@ -1,12 +1,16 @@
+import importlib.util
 from typing import Any, Callable
+from unittest.mock import MagicMock
 
 import pytest
+from pydantic import BaseModel
 
 from guardrails.llm_providers import (
     ArbitraryCallable,
     AsyncArbitraryCallable,
     LLMResponse,
     PromptCallableException,
+    chat_prompt,
     get_llm_ask,
 )
 
@@ -193,6 +197,90 @@ async def test_async_openai_chat_callable(mocker, openai_chat_mock):
     assert response.response_token_count == 20
 
 
+def test_openai_chat_model_callable(mocker, openai_chat_mock):
+    mocker.patch("openai.ChatCompletion.create", return_value=openai_chat_mock)
+
+    from guardrails.llm_providers import OpenAIChatCallable
+
+    class MyModel(BaseModel):
+        a: str
+
+    openai_chat_model_callable = OpenAIChatCallable()
+    response = openai_chat_model_callable(
+        text="Hello",
+        base_model=MyModel,
+    )
+
+    assert isinstance(response, LLMResponse) is True
+    assert response.output == "Mocked LLM output"
+    assert response.prompt_token_count == 10
+    assert response.response_token_count == 20
+
+
+@pytest.mark.asyncio
+async def test_async_openai_chat_model_callable(mocker, openai_chat_mock):
+    mocker.patch("openai.ChatCompletion.acreate", return_value=openai_chat_mock)
+
+    from guardrails.llm_providers import AsyncOpenAIChatCallable
+
+    class MyModel(BaseModel):
+        a: str
+
+    openai_chat_model_callable = AsyncOpenAIChatCallable()
+    response = await openai_chat_model_callable(
+        text="Hello",
+        base_model=MyModel,
+    )
+
+    assert isinstance(response, LLMResponse) is True
+    assert response.output == "Mocked LLM output"
+    assert response.prompt_token_count == 10
+    assert response.response_token_count == 20
+
+
+@pytest.mark.skipif(
+    not importlib.util.find_spec("manifest"),
+    reason="manifest-ml is not installed",
+)
+def test_manifest_callable():
+    client = MagicMock()
+    client.run.return_value = "Hello world!"
+
+    from guardrails.llm_providers import ManifestCallable
+
+    manifest_callable = ManifestCallable()
+    response = manifest_callable(text="Hello", client=client)
+
+    assert isinstance(response, LLMResponse) is True
+    assert response.output == "Hello world!"
+    assert response.prompt_token_count is None
+    assert response.response_token_count is None
+
+
+@pytest.mark.skipif(
+    not importlib.util.find_spec("manifest"),
+    reason="manifest-ml is not installed",
+)
+@pytest.mark.asyncio
+async def test_async_manifest_callable():
+    client = MagicMock()
+
+    async def return_async():
+        return ["Hello world!"]
+
+    client.arun_batch.return_value = return_async()
+
+    from guardrails.llm_providers import AsyncManifestCallable
+
+    manifest_callable = AsyncManifestCallable()
+    response = await manifest_callable(text="Hello", client=client)
+
+    assert isinstance(response, LLMResponse) is True
+    assert response.output == "Hello world!"
+    assert response.prompt_token_count is None
+    assert response.response_token_count is None
+
+
 class ReturnTempCallable(Callable):
     def __call__(*args, **kwargs) -> Any:
         return kwargs.get("temperature")
@@ -208,3 +296,9 @@ def __call__(*args, **kwargs) -> Any:
 def test_get_llm_ask_temperature(llm_api, args, kwargs, expected_temperature):
     result = get_llm_ask(llm_api, *args, **kwargs)
     assert result().output == str(expected_temperature)
+
+
+def test_chat_prompt():
+    # raises when neither msg_history or prompt are provided
+    with pytest.raises(PromptCallableException):
+        chat_prompt(None)
diff --git a/tests/unit_tests/test_prompt.py b/tests/unit_tests/test_prompt.py
index 9d0c09162..40f2d1c08 100644
--- a/tests/unit_tests/test_prompt.py
+++ b/tests/unit_tests/test_prompt.py
@@ -7,9 +7,11 @@
 from pydantic import BaseModel, Field
 
 import guardrails as gd
+from guardrails.prompt.instructions import Instructions
+from guardrails.prompt.prompt import Prompt
 from guardrails.utils.constants import constants
 
-INSTRUCTIONS = "You are a helpful bot, who answers only with valid JSON"
+INSTRUCTIONS = "\nYou are a helpful bot, who answers only with valid JSON\n"
 
 PROMPT = "Extract a string from the text"
 
@@ -204,12 +206,14 @@ def test_format_instructions():
 
 def test_reask_prompt():
     guard = gd.Guard.from_rail_string(RAIL_WITH_REASK_PROMPT)
-    assert guard.output_schema.reask_prompt_template == REASK_PROMPT
+    assert guard.output_schema.reask_prompt_template == Prompt(REASK_PROMPT)
 
 
 def test_reask_instructions():
     guard = gd.Guard.from_rail_string(RAIL_WITH_REASK_INSTRUCTIONS)
-    assert guard.output_schema.reask_instructions_template.strip() == INSTRUCTIONS
+    assert guard.output_schema._reask_instructions_template == Instructions(
+        INSTRUCTIONS
+    )
 
 
 @pytest.mark.parametrize(
@@ -270,3 +274,18 @@ def test_gr_dot_prefixed_prompt_item_fails():
         # From pydantic:
         prompt = """Give me a response to ${gr.ade}"""
         gd.Guard.from_pydantic(output_class=TestResponse, prompt=prompt)
+
+
+def test_escape():
+    prompt_string = (
+        'My prompt with a some sample json { "a" : 1 } and a {f_var} and a ${safe_var}'
+    )
+    prompt = Prompt(prompt_string)
+
+    escaped_prompt = prompt.escape()
+
+    assert prompt.source == prompt_string
+    assert (
+        escaped_prompt
+        == 'My prompt with a some sample json {{ "a" : 1 }} and a {{f_var}} and a ${safe_var}'  # noqa
+    )
diff --git a/tests/unit_tests/test_reask_utils.py b/tests/unit_tests/test_reask_utils.py
index e27a3ef0b..75e43e2d3 100644
--- a/tests/unit_tests/test_reask_utils.py
+++ b/tests/unit_tests/test_reask_utils.py
@@ -1,13 +1,15 @@
 import pytest
-from lxml.etree import Element, SubElement, tostring
+from lxml.etree import Element, SubElement
 
+from guardrails.datatypes import Object
+
+# from guardrails.datatypes import Object
 from guardrails.utils.reask_utils import (
     FieldReAsk,
-    gather_reasks,
     get_pruned_tree,
     prune_obj_for_reasking,
 )
-from guardrails.validators import FailResult, PydanticReAsk
+from guardrails.validators import FailResult
 
 # FIXME: These tests are not exhaustive.
 # They only add missing coverage from the 0.2 release
@@ -15,42 +17,40 @@
 #   and use Integration tests for mimicking user flows
 
 
-def test_gather_reasks():
-    reask = PydanticReAsk()
-    reask["failed_prop"] = FieldReAsk(
-        path=["$.failed_prop.child"],
-        fail_results=[
-            FailResult(error_message="child should not be None", outcome="fail")
-        ],
-        incorrect_value=None,
-    )
-
-    gathered_reasks = gather_reasks(reask)
-
-    assert len(gathered_reasks) == 1
-    assert gathered_reasks[0] == reask["failed_prop"]
-
-
 empty_root = Element("root")
 non_empty_root = Element("root")
-property = SubElement(non_empty_root, "property")
-property.attrib["format"] = "two-words"
-child = SubElement(property, "child")
+property = SubElement(non_empty_root, "list", name="dummy")
+property.attrib["format"] = "length: 2"
+child = SubElement(property, "string")
 child.attrib["format"] = "two-words"
 non_empty_output = Element("root")
-output_property = SubElement(non_empty_output, "property")
-output_child = SubElement(output_property, "child")
+output_property = SubElement(non_empty_output, "list", name="dummy")
+output_property.attrib["format"] = "length: 2"
+output_child = SubElement(output_property, "string")
 output_child.attrib["format"] = "two-words"
 
 
 @pytest.mark.parametrize(
     "root,reasks,expected_output",
-    [(empty_root, None, empty_root), (non_empty_root, [child], non_empty_output)],
+    [
+        (empty_root, None, empty_root),
+        (
+            non_empty_root,
+            [
+                FieldReAsk(
+                    incorrect_value="",
+                    fail_results=[FailResult(error_message="child should not be None")],
+                    path=["dummy", 0],
+                )
+            ],
+            non_empty_output,
+        ),
+    ],
 )
 def test_get_pruned_tree(root, reasks, expected_output):
-    actual_output = get_pruned_tree(root, reasks)
+    actual_output = get_pruned_tree(Object.from_xml(root), reasks)
 
-    assert tostring(actual_output) == tostring(expected_output)
+    assert actual_output == Object.from_xml(expected_output)
 
 
 def test_prune_obj_for_reasking():
diff --git a/tests/unit_tests/test_skeleton.py b/tests/unit_tests/test_skeleton.py
index 5db91172c..4e9eec3f8 100644
--- a/tests/unit_tests/test_skeleton.py
+++ b/tests/unit_tests/test_skeleton.py
@@ -1,6 +1,7 @@
 import lxml.etree as ET
 import pytest
 
+from guardrails.datatypes import Object
 from guardrails.utils.json_utils import verify_schema_against_json
 
 
@@ -296,11 +297,5 @@
 )
 def test_skeleton(xml, generated_json, result, coerce_types):
     xml_schema = ET.fromstring(xml)
-    assert (
-        verify_schema_against_json(
-            xml_schema,
-            generated_json,
-            coerce_types=coerce_types,
-        )
-        is result
-    )
+    datatype = Object.from_xml(xml_schema)
+    assert verify_schema_against_json(datatype, generated_json) is result
diff --git a/tests/unit_tests/test_validators.py b/tests/unit_tests/test_validators.py
index 927553198..7b98a9c8b 100644
--- a/tests/unit_tests/test_validators.py
+++ b/tests/unit_tests/test_validators.py
@@ -10,25 +10,27 @@
 from guardrails.datatypes import DataType
 from guardrails.schema import StringSchema
 from guardrails.utils.reask_utils import FieldReAsk
+from guardrails.validator_base import (
+    FailResult,
+    Filter,
+    PassResult,
+    Refrain,
+    ValidationResult,
+    check_refrain_in_dict,
+    filter_in_dict,
+    register_validator,
+)
 from guardrails.validators import (
     BugFreeSQL,
     DetectSecrets,
     ExtractedSummarySentencesMatch,
     ExtractiveSummary,
-    FailResult,
-    Filter,
-    PassResult,
     ProvenanceV1,
-    Refrain,
     SimilarToDocument,
     SimilarToList,
     SqlColumnPresence,
     TwoWords,
-    ValidationResult,
     ValidLength,
-    check_refrain_in_dict,
-    filter_in_dict,
-    register_validator,
 )
 
 from .mock_embeddings import MOCK_EMBEDDINGS, mock_create_embedding
@@ -373,7 +375,7 @@ def test_provenance_v1(mocker):
     )
 
     output_schema: StringSchema = string_guard.rail.output_schema
-    data_type: DataType = getattr(output_schema._schema, "string")
+    data_type: DataType = output_schema.root_datatype
     validators = data_type.format_attr.validators
     prov_validator: ProvenanceV1 = validators[0]
 
diff --git a/tests/unit_tests/utils/test_parsing_utils.py b/tests/unit_tests/utils/test_parsing_utils.py
index 90d44417c..462c998ae 100644
--- a/tests/unit_tests/utils/test_parsing_utils.py
+++ b/tests/unit_tests/utils/test_parsing_utils.py
@@ -1,6 +1,10 @@
 import pytest
 
-from guardrails.utils.parsing_utils import get_code_block, has_code_block
+from guardrails.utils.parsing_utils import (
+    get_code_block,
+    get_template_variables,
+    has_code_block,
+)
 
 json_code_block = """
 ```json
@@ -74,3 +78,10 @@ def test_get_code_block(llm_ouput, expected_output, code_type):
     actual_output = get_code_block(llm_ouput, start, end, code_type)
 
     assert actual_output == expected_output
+
+
+def test_get_template_variables():
+    string_template = "${my_var} $my_second_var {not_a_var}"
+    vars = get_template_variables(string_template)
+
+    assert vars == ["my_var", "my_second_var"]
diff --git a/tests/unit_tests/utils/test_reask_utils.py b/tests/unit_tests/utils/test_reask_utils.py
index c60157b78..c8d1edc92 100644
--- a/tests/unit_tests/utils/test_reask_utils.py
+++ b/tests/unit_tests/utils/test_reask_utils.py
@@ -4,6 +4,7 @@
 from lxml import etree as ET
 
 from guardrails import Instructions, Prompt
+from guardrails.datatypes import Object
 from guardrails.schema import JsonSchema
 from guardrails.utils import reask_utils
 from guardrails.utils.logs_utils import GuardLogs
@@ -340,13 +341,18 @@ def test_prune_json_for_reasking(input_dict, expected_dict):
 
 
 @pytest.mark.parametrize(
-    "example_rail, reasks, original_response, reask_json",
+    "example_rail, expected_rail, reasks, original_response, reask_json",
     [
         (
             """
 <output>
     <string name="name" required="true"/>
 </output>
+""",
+            """
+<output>
+    <string name="name"/>
+</output>
 """,
             [
                 FieldReAsk(
@@ -377,6 +383,12 @@ def test_prune_json_for_reasking(input_dict, expected_dict):
     <string name="name" required="true"/>
     <integer name="age" required="true"/>
 </output>
+""",
+            """
+<output>
+    <string name="name"/>
+    <integer name="age"/>
+</output>
 """,
             [
                 FieldReAsk(
@@ -419,7 +431,9 @@ def test_prune_json_for_reasking(input_dict, expected_dict):
         ),
     ],
 )
-def test_get_reask_prompt(example_rail, reasks, original_response, reask_json):
+def test_get_reask_prompt(
+    example_rail, expected_rail, reasks, original_response, reask_json
+):
     """Test that get_reask_prompt function returns the correct prompt."""
     expected_result_template = """
 I was given the following JSON response, which had problems due to incorrect values.
@@ -443,7 +457,7 @@ def test_get_reask_prompt(example_rail, reasks, original_response, reask_json):
 - `<list name='bar'><string format='upper-case' /></list>` => `{"bar": ['STRING ONE', 'STRING TWO', etc.]}`
 - `<object name='baz'><string name="foo" format="capitalize two-words" /><integer name="index" format="1-indexed" /></object>` => `{'baz': {'foo': 'Some String', 'index': 1}}`
 """  # noqa: E501
-    output_schema = JsonSchema(ET.fromstring(example_rail))
+    output_schema = JsonSchema(Object.from_xml(ET.fromstring(example_rail)))
     guard_logs = GuardLogs()
     validated = output_schema.validate(guard_logs, original_response, {})
     reasks = output_schema.introspect(validated)
@@ -457,7 +471,7 @@ def test_get_reask_prompt(example_rail, reasks, original_response, reask_json):
         expected_result_template
         % (
             json.dumps(reask_json, indent=2),
-            example_rail,
+            expected_rail,
         )
     )
 
diff --git a/tests/unit_tests/utils/test_xml_utils.py b/tests/unit_tests/utils/test_xml_utils.py
new file mode 100644
index 000000000..83add3665
--- /dev/null
+++ b/tests/unit_tests/utils/test_xml_utils.py
@@ -0,0 +1,22 @@
+import pytest
+
+from guardrails.utils.xml_utils import cast_xml_to_string
+
+
+@pytest.mark.parametrize(
+    "xml_input, expected_output",
+    [
+        # str
+        ("hello", "hello"),
+        # bytes
+        (b"hello", "hello"),
+        # bytearray
+        (bytearray(b"hello"), "hello"),
+        # memoryview
+        (memoryview(b"hello"), "hello"),
+    ],
+)
+def test_xml_element_cast(xml_input, expected_output):
+    cast_output = cast_xml_to_string(xml_input)
+    assert cast_output == expected_output
+    assert isinstance(cast_output, str)