Repository: dag-hammarskjold-library/dlx

Issue:
Marc.get_value() returns None if subfield is not in first instance of tag
For example a record with the below fields will return None on bib.get_value('069', 'b). It should return "Test2".
```
=069  \\$aTest1
=069  \\$bTest2
```

Hints from issue comments:

Solution patch:
diff --git a/dlx/marc/__init__.py b/dlx/marc/__init__.py
index 56fa5243..968aa7d1 100644
--- a/dlx/marc/__init__.py
+++ b/dlx/marc/__init__.py
@@ -1,6 +1,6 @@
 """dlx.marc"""
 
-import time, re, json, threading, copy, typing
+import time, re, json, csv, threading, copy, typing
 from collections import Counter
 from datetime import datetime, timezone
 from warnings import warn
@@ -139,14 +139,17 @@ def sort_table_header(cls, header: list) -> list:
         # Sorts the header of a MarcSet in util.Table for for use in 
         # (de)serialization
 
-        return sorted(
-            header, 
-            key=lambda x: ( 
-                (re.match(r'\d+\.(\w+)', x)).group(1), # sort by tag
-                int(re.match(r'(\d+)\.', x).group(1)), # sort by prefix group
-                (re.match(r'\d+\.\d{3}\$?(\w)?', x)).group(1) # sort by subfield code
+        try:
+            return sorted(
+                header, 
+                key=lambda x: ( 
+                    (re.match(r'\d+\.(\w+)', x)).group(1), # sort by tag
+                    int(re.match(r'(\d+)\.', x).group(1)), # sort by prefix group
+                    (re.match(r'\d+\.\d{3}\$?(\w)?', x)).group(1) # sort by subfield code
+                )
             )
-        )
+        except AttributeError:
+            raise Exception('Table header could not be parsed: ' + str(header))
 
     @classmethod
     def from_table(cls, table, auth_control=True, auth_flag=False, field_check=None, delete_subfield_zero=True):
@@ -599,23 +602,35 @@ def get_values(self, tag, *codes, **kwargs):
 
         return list(filter(None, values))
 
-    def get_value(self, tag, code=None, *, address=[0, 0], language=None):
-        if len(address) != 2:
-            raise Exception('Keyword agrgument "address" must be an iterable containing two ints')
-            
-        field = self.get_field(tag, place=address[0])    
-
-        if isinstance(field, Controlfield):
-            return field.value
+    def get_value(self, tag: str, code: str = None, *, address: list = None, language: str = None) -> str:
+        '''Returns a single value from a tag and subfield code combo. If an
+        address is given, the value at that address is returned. If no address
+        is given, the value of first instance of the tag and subfield in the
+        record is returned.
+        '''
+        
+        if tag[:2] == '00':
+            if field := self.get_field(tag, place=0 if address is None else address[0]):
+                return field.value
+        else:
+            subfield = None
 
-        if isinstance(field, Datafield):
-            sub = field.get_subfield(code, place=address[1])
+            if address:
+                if len(address) != 2:
+                    raise Exception('Keyword agrgument "address" must be an iterable containing two ints')
+                else:
+                    if field := self.get_field(tag, place=address[0]):
+                        subfield = field.get_subfield(code, place=address[1])
+            else:
+                # get first instance of the subfield code  
+                for field in self.get_fields(tag):
+                    subfield = field.get_subfield(code)
 
-            if sub:
+            if subfield:
                 if language:
-                    return sub.translated(language)
+                    return subfield.translated(language)
                 else:
-                    return sub.value
+                    return subfield.value
 
         return ''
 
@@ -1536,6 +1551,19 @@ def from_mrk(cls, string: str, auth_control=True, delete_subfield_zero=True):
 
         return self
     
+    @classmethod
+    def from_table(cls, list_of_lists: list[list], auth_control=False, delete_subfield_zero=True):
+        if len(list_of_lists) != 2:
+            raise Exception('Table must contain exactly one header line and one data line: ' + str(list_of_lists))
+
+        return cls.set_class.from_table(Table(list_of_lists)).records[0]
+    
+    @classmethod
+    def from_csv(cls, string: str, auth_control=False, delete_subfield_zero=True):
+        rows = [row for row in csv.reader(string.split('\n'))]
+
+        return cls.set_class.from_table(Table(rows), auth_control=auth_control).records[0]
+
     @classmethod
     def from_xml_raw(cls, root: ElementTree.Element, *, auth_control=True, delete_subfield_zero=True):
         if DB.database_name == 'testing' or Config.threading == False:
diff --git a/requirements.txt b/requirements.txt
index b09c9c55..33f7efee 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,11 +1,11 @@
-boto3==1.37.0
+boto3==1.37.10
 certifi==2025.1.31
 jsonschema==4.23.0
 mongomock==4.3.0
-moto==5.1.0
+moto==5.1.1
 nltk==3.9.1
 pymongo==4.10.1
-pytest==8.3.4
+pytest==8.3.5
 requests>=2.32.2
 responses==0.25.6
 xlrd==2.0.1


Test patch:
diff --git a/tests/test_marc.py b/tests/test_marc.py
index 6df77234..c9804572 100644
--- a/tests/test_marc.py
+++ b/tests/test_marc.py
@@ -524,6 +524,10 @@ def test_get_value(db, bibs):
     assert bib.get_value('999', 'a') == ''
     assert bib.get_values('999', 'a') == []
 
+    # issue 487
+    bib.set('520', 'z', 'Extra subfield', address=['+'])
+    assert bib.get_value('520', 'z') == 'Extra subfield'
+
 def test_get_values(db, bibs):
     from dlx.marc import Bib
 
@@ -764,7 +768,20 @@ def test_from_mrk(db):
     bib.id = 1
     assert bib.to_mrk() == control
     assert bib.commit(auth_check=True)
-    
+
+def test_from_csv(db, bibs):
+    from dlx.marc import Bib
+
+    bib = Bib.from_csv('1.245$a,1.269$a,1.650$a\nTitle,Date,Not validated')
+    assert isinstance(bib, Bib)
+    assert Bib.from_csv('1.245$a,1.269$a,1.650$a\nTitle,Date,Header', auth_control=True)
+
+    with pytest.raises(Exception):
+        Bib.from_csv('1.245$a,1.269$a,1.650$a\nTitle,Date,Invalid auth controlled value', auth_control=True)
+
+    with pytest.raises(Exception):
+        Bib.from_csv('Invalid header value,1.269$a,1.650$a\nTitle,Date,Header')
+
 def test_from_json():
     from dlx.marc import Bib, InvalidAuthValue, InvalidAuthField