Repository: biopython/biopython

Issue:
SearchIO model pop has untested and potentially unused parameter default
I am not quite sure what to make of the default parameter here:

https://github.com/biopython/biopython/blob/870f623005ed1b95df877f618cbb12a815bd5696/Bio/SearchIO/_model/query.py#L599-L666

I don't see the added value of adding this default. Am I missing something? 

A simple `popped = d.pop() if d else default` does the trick without all the extra object instantiation and testing.

I suggest removing the default parameter to make the pop behave as a pop on a builtin dict.


Discussion:
Hi @fabianegli 

Good to see a suggestion here :). The reason for this implementation is actually to mimic the default `dict.pop` behavior, with a possibility of setting a default value if the value does not exist (as the docstring and comment says).

`d.pop("foo")` raises a KeyError if `foo` doesn't exist. This is a behavior that we would like to mirror in `QueryResult`.

However, I do also think it is handy to set a default (like in `getattr`), which will be the return value if the key does not exist. A custom `__marker` object was chosen, because this is the only way to detect whether a default has been set or not. `None` does not suffice, as it can also be used as the given default value.

So in short, adding a `default` this way is to make the `pop` function more concise (ala `getattr`) but still keep a default behavior that is the same as `dict.pop`.

EDIT: I should also say, the example you pasted is a little different, right? It checks for the boolean evaluation of the `d` object, but not whether any specific keys exist in `d` or not.
Doesn't the Python dict's pop have a default argument?

```
pop(...) method of builtins.dict instance
    D.pop(k[,d]) -> v, remove specified key and return the corresponding value.
    If key is not found, d is returned if given, otherwise KeyError is raised
```

> Doesn't the Python dict's pop have a default argument?
> 
> ```
> pop(...) method of builtins.dict instance
>     D.pop(k[,d]) -> v, remove specified key and return the corresponding value.
>     If key is not found, d is returned if given, otherwise KeyError is raised
> ```

Actually, it does indeed. Not documented explicitly in the docstring, but indeed it can be set:

```
>>> {"a": 1}.pop("b")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'b'
>>> {"a": 1}.pop("b", 100)
100
>>> 
```

So yes, this is actually then to mimic the stdlib `.pop` method :).
@bow My example in the question is indeed not very useful. I am glad the discussion still developed in the direction I hoped it would. 

 I now see the beauty in using an object as a "someting" that can reasonably be expected to never be the default set by a user.

After looking a little bit longer at the code I think there are two code cleanups that might be considered:

1. Remove the default parameter in the pop function in line 661. If the key is in the dict, which the preceding if tests for, the default will never be needed. Also, at this point, if it were needed, it could as well be the __marker object which should never be returned.
2. Unindent lines 662 to 664. At this point a return has been hit if the key exists. This would put all the default related handling on the same level.
@fabianegli That sounds good :). I'll respond to your points below.

> Remove the default parameter in the pop function in line 661. If the key is in the dict, which the preceding if tests for, the default will never be needed. Also, at this point, if it were needed, it could as well be the __marker object which should never be returned.

Yes ~ indeed if the hit ID value resolves to an alternative hit ID, we know that the `self.pop` would return a value and therefore `default` is essentially uneeded.

> Unindent lines 662 to 664. At this point a return has been hit if the key exists. This would put all the default related handling on the same level.

This breaks the function, right? I'm assuming you meant unindenting it out of the `KeyError` except block. We only want to raise `KeyError` if the following conditions are met:

* The hit ID is not a primary ID
* The hit ID is not an alternative ID
* No default value is ever supplied

Which is checkable only at that indentation level, right?
@bow Indeed, I meant unindenting one level to take the if and its content out of the exception block. If any of the first two conditions in your condition list is not met (careful here, it's a double negation) it will result in a return from the method - so these conditions are met after the try except block -  and the last condition is being checked by the if statement itself. So taking it out from the try/except block makes sense to me. For me it also makes sense to have the check if a default has been set on the same level as the `return default` because this is the level where default is going to be used.

Resolving patch:
diff --git a/Bio/SearchIO/_model/query.py b/Bio/SearchIO/_model/query.py
--- a/Bio/SearchIO/_model/query.py
+++ b/Bio/SearchIO/_model/query.py
@@ -650,19 +650,18 @@ def pop(self, hit_key=-1, default=__marker):
             hit = self._items.pop(hit_key)
             # remove all alternative IDs of the popped hit
             for alt_id in hit.id_all[1:]:
-                try:
-                    del self.__alt_hit_ids[alt_id]
-                except KeyError:
-                    pass
-            return hit
+                self.__alt_hit_ids.pop(alt_id, None)
         except KeyError:
-            if hit_key in self.__alt_hit_ids:
-                return self.pop(self.__alt_hit_ids[hit_key], default)
-            # if key doesn't exist and no default is set, raise a KeyError
-            if default is self.__marker:
-                raise KeyError(hit_key) from None
-        # if key doesn't exist but a default is set, return the default value
-        return default
+            try:
+                hit = self.pop(self.__alt_hit_ids[hit_key])
+            except KeyError:
+                # hit_key is not a valid id
+                # use the default if it has been set
+                if default is not self.__marker:
+                    hit = default
+                else:
+                    raise KeyError(hit_key) from None
+        return hit
 
     def index(self, hit_key):
         """Return the index of a given hit key, zero-based.


Test patch:
diff --git a/Tests/test_SearchIO_model.py b/Tests/test_SearchIO_model.py
--- a/Tests/test_SearchIO_model.py
+++ b/Tests/test_SearchIO_model.py
@@ -647,6 +647,18 @@ def test_hsp_map_no_func(self):
         self.assertEqual(1102, mapped.seq_len)
         self.assertEqual("refseq_rna", mapped.target)
 
+    def test_pop_nonexistent_with_default(self):
+        """Test QueryResult.pop with default for nonexistent key."""
+        default = "An arbitrary default return value for this test only."
+        nonexistent_key = "neither a standard nor alternative key"
+        hit = self.qresult.pop(nonexistent_key, default)
+        self.assertEqual(hit, default)
+
+    def test_pop_nonexistent_key(self):
+        """Test QueryResult.pop with default for nonexistent key."""
+        nonexistent_key = "neither a standard nor alternative key"
+        self.assertRaises(KeyError, self.qresult.pop, nonexistent_key)
+
     def test_pop_ok(self):
         """Test QueryResult.pop."""
         self.assertEqual(3, len(self.qresult))