Repository: isaacharrisholt/quiffen

Issue:
Security without symbol causes an error
Thanks for your work on quiffen.

I have a qif file from a Quicken export with a security that is not publicly traded, so it has a name and no symbol.

That produces an error with from Qif.parse using quiffen 2.0.13, shown below.

```
$ ./example.py
Traceback (most recent call last):
  File ".../example.py", line 6, in <module>
    qif = Qif.parse('ex.QIF', day_first=False)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../.venv/lib/python3.12/site-packages/quiffen/core/qif.py", line 249, in parse
    raise ParserException(
quiffen.core.qif.ParserException: Line 2: No symbol found for security.
```

Here is an ex.QIF:

```
^
!Type:Security
NExample
TStock
```

In my local copy, I used the name as the symbol with the following patch:

```
$ diff -p .venv/lib/python3.12/site-packages/quiffen/core/security.py tmp/security.py 
*** .venv/lib/python3.12/site-packages/quiffen/core/security.py	2024-12-25 13:39:50.000000000 -0600
--- tmp/security.py	2024-12-11 18:47:15.000000000 -0600
*************** class Security(BaseModel):
*** 128,133 ****
--- 128,136 ----
          if line_number is not None:
              kwargs["line_number"] = line_number
  
+         # if symbol is not present, use the name
+         kwargs["symbol"] = kwargs.get("symbol", kwargs.get("name"))
+ 
          return cls(**kwargs)
  
      @classmethod
```

I'd be happy to submit that as a PR if you wish.

Hints from issue comments:
@isaacharrisholt: Hey, thanks for raising this. I didn't know it was a thing. That seems like a reasonable fix, as long as it's called after all the arguments have been processed (which it looks like you're doing). A PR would be great

Solution patch:
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/security.py b/quiffen/core/security.py
index 9ce7099..07f65d5 100644
--- a/quiffen/core/security.py
+++ b/quiffen/core/security.py
@@ -128,6 +128,9 @@ class Security(BaseModel):
         if line_number is not None:
             kwargs["line_number"] = line_number
 
+        # if symbol is not present, use the name
+        kwargs["symbol"] = kwargs.get("symbol", kwargs.get("name"))
+
         return cls(**kwargs)
 
     @classmethod


Test patch:
diff --git a/tests/test_qif.py b/tests/test_qif.py
index 6e8fbb1..c3f6bb3 100644
--- a/tests/test_qif.py
+++ b/tests/test_qif.py
@@ -59,6 +59,11 @@ def qif_file_with_unknown_account_type():
     return Path(__file__).parent / "test_files" / "test_unknown_account_type.qif"
 
 
+@pytest.fixture
+def qif_file_with_no_symbol():
+    return Path(__file__).parent / "test_files" / "test_nosymbol.qif"
+
+
 def test_create_qif():
     """Test creating a Qif instance"""
     qif = Qif()
@@ -217,12 +222,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)
 
@@ -369,6 +395,13 @@ def test_parsed_securities(qif_file):
     assert qif.securities["USD0000"].goal == "Growth"
 
 
+def test_parse_nosymbol(qif_file_with_no_symbol):
+    """Test that a security with no symbol can still be parsed without error"""
+    qif = Qif.parse(qif_file_with_no_symbol)
+
+    assert list(qif.securities.keys()) == ["Example"]
+
+
 def test_add_account():
     """Test adding an account"""
     qif = Qif()
diff --git a/tests/test_security.py b/tests/test_security.py
index af1bebf..5d1e4cd 100644
--- a/tests/test_security.py
+++ b/tests/test_security.py
@@ -172,6 +172,21 @@ def test_from_list_with_unknown_line_code():
         Security.from_list(qif_list)
 
 
+def test_from_list_no_symbol():
+    """Test creating a security from a list with no custom fields"""
+    qif_list = [
+        "NTest Security",
+        "TTest Type",
+        "GTest Goal",
+    ]
+    security = Security.from_list(qif_list)
+    assert security.name == "Test Security"
+    # Since we need a symbol, use the name as the symbol
+    assert security.symbol == security.name
+    assert security.type == "Test Type"
+    assert security.goal == "Test Goal"
+
+
 def test_from_string_default_separator():
     """Test creating a security from a string with the default separator"""
     qif_string = "NTest Security\n" "STest Symbol\n" "TTest Type\n" "GTest Goal\n"