Repository: isaacharrisholt/quiffen

Issue:
Allow data parsing without needing a file
I have a use case for parsing QIF files via an API. As result, there is no longer a file for `parse` to take as input. While I could save it as a temporary file and send that in, it seems an unnecessary step when the first few lines of `parse` could be split into a new function i.e. `parse_file` and then `parse` could simply take a string of the file contents.

I appreciate this would cause breaking changes, so perhaps a less breaking change would be to rename `parse` to `parse_str` or similar, and then remake the `parse` function with the beginning lines to read the file and pass it on to `parse_str`.

I'm willing to make a PR of the changes with the appropriate test modifications and additions once #105 is merged. Also, a discussion about any other possible parse versions could be had (bytes, stream, etc.)

Hints from issue comments:
@isaacharrisholt: I'd be happy to add a `parse_str` or `parse_string` method, but yes, I'd like to avoid breaking API changes@isaacharrisholt: Closing now that we have a `parse_string` function

Solution patch:
diff --git a/quiffen/core/base.py b/quiffen/core/base.py
index 7973bf7..e419b30 100644
--- a/quiffen/core/base.py
+++ b/quiffen/core/base.py
@@ -1,10 +1,8 @@
 from abc import abstractmethod
-from typing import Any, Dict, Generic, Iterable, List, Optional, Type, TypeVar
+from typing import Any, Dict, Iterable, List, Optional, Type, TypeVar
 
 from pydantic import BaseModel as PydanticBaseModel
 
-T = TypeVar("T")
-
 
 class Field(PydanticBaseModel):
     """A custom field for a QIF object.
@@ -38,7 +36,10 @@ class Field(PydanticBaseModel):
         )
 
 
-class BaseModel(PydanticBaseModel, Generic[T]):
+TModel = TypeVar("TModel", bound="BaseModel")
+
+
+class BaseModel(PydanticBaseModel):
     class Config:
         extra = "allow"
 
@@ -85,11 +86,11 @@ class BaseModel(PydanticBaseModel, Generic[T]):
 
     @classmethod
     @abstractmethod
-    def from_list(cls, lst: List[str]) -> T:
+    def from_list(cls: Type[TModel], lst: List[str]) -> TModel:
         pass
 
     @classmethod
-    def from_string(cls, string: str, separator: str = "\n") -> T:
+    def from_string(cls: Type[TModel], string: str, separator: str = "\n") -> TModel:
         """Create a class instance from a string."""
         return cls.from_list(string.split(separator))
 
diff --git a/quiffen/core/qif.py b/quiffen/core/qif.py
index 53796ad..6d260bb 100644
--- a/quiffen/core/qif.py
+++ b/quiffen/core/qif.py
@@ -137,6 +137,32 @@ class Qif(BaseModel):
         if not data:
             raise ParserException("The file is empty.")
 
+        return cls.parse_string(data, separator=separator, day_first=day_first)
+
+    @classmethod
+    def parse_string(
+        cls, data: str, separator: str = "\n", day_first: bool = False
+    ) -> Qif:
+        """Return a class instance from a string containing QIF data.
+
+        Parameters
+        ----------
+        data : str
+            The string of QIF data
+        separator : str, default='\n'
+            The line separator for the QIF file. This probably won't need
+            changing.
+        day_first : bool, default=False
+            Whether the day or month comes first in the date.
+
+        Returns
+        -------
+        Qif
+            A Qif object containing all the data in the QIF file.
+        """
+        if not data:
+            raise ParserException("The data string is empty.")
+
         accounts: Dict[str, Account] = {}
         last_account = None
         categories: Dict[str, Category] = {}
diff --git a/quiffen/core/transaction.py b/quiffen/core/transaction.py
index 99d9dc3..a8c3747 100644
--- a/quiffen/core/transaction.py
+++ b/quiffen/core/transaction.py
@@ -319,7 +319,7 @@ class Transaction(BaseModel):
         return qif
 
     @classmethod
-    def from_list(
+    def from_list(  # type: ignore - this one needs an incompatible return type
         cls,
         lst: List[str],
         day_first: bool = False,


Test patch:
diff --git a/tests/test_qif.py b/tests/test_qif.py
index 6e8fbb1..80bfe7a 100644
--- a/tests/test_qif.py
+++ b/tests/test_qif.py
@@ -217,12 +217,33 @@ def test_from_list():
         qif.from_list([])
 
 
-def test_parse_works(qif_file):
+def test_parse_string_works():
+    """Test parsing a QIF file"""
+    qif_string = (
+        "!Type:Class\n"
+        "NTest class\n"
+        "DThis is just a class I added here for test purposes\n"
+        "^\n"
+        "!Type:Class\n"
+        "NTest class 2\n"
+        "DThis is just a class I added here for test purposes\n"
+        "^\n"
+    )
+    Qif.parse_string(qif_string)
+
+
+def test_parse_empty_string():
+    """Test parsing an empty QIF file"""
+    with pytest.raises(ParserException):
+        Qif.parse("")
+
+
+def test_parse_file_works(qif_file):
     """Test parsing a QIF file"""
     Qif.parse(qif_file)
 
 
-def test_parse_works_with_oth_a_account(qif_file_with_oth_a_account):
+def test_parse_file_works_with_oth_a_account(qif_file_with_oth_a_account):
     """Test parsing a QIF file with an OTH account"""
     Qif.parse(qif_file_with_oth_a_account)