Repository: biopython/biopython

Issue:
AttributeError: 'SeqFeature' object has no attribute 'strand'
## Setup
- **Biopython Version:** 1.82
- **Python Version:** 3.11.5 (main, Sep 27 2023, 11:42:37) [GCC 11.4.0]
- **Operating System:** Linux-5.15.0-91-generic-x86_64-with-glibc2.35
- **Python Implementation:** CPython

## Expected Behaviour
When processing GenBank files using Biopython, each gene feature should have a 'strand' attribute.

## Actual Behaviour
The 'strand' attributes are missing for gene features in GenBank files.

## Steps to Reproduce
1. Run the following script to parse a GenBank file and check for the presence of 'strand' attributes in gene features:
   ```python
   from Bio import SeqIO

   import sys; print(sys.version)
   import platform; print(platform.python_implementation()); print(platform.platform())
   import Bio; print(Bio.__version__)

   def check_strand_in_genes(genome_file):
       count = 0
       for record in SeqIO.parse(genome_file, "genbank"):
           for feature in record.features:
               if feature.type == "gene":
                   gene_name = feature.qualifiers.get('gene', ['Unknown'])[0]
                   if hasattr(feature, 'strand'):
                       print(f"'strand' attribute exists for {gene_name} in record {record.id}")
                       count += 1
                   else:
                       print(f"'strand' attribute does not exist for {gene_name} in record {record.id}")
                       count += 1
                   if count >= 5:
                       return

   # Replace 'genome_file.gb' with the path to your GenBank file
   # Example file: https://www.ncbi.nlm.nih.gov/nuccore/U00096.3/
   check_strand_in_genes('sequence.gb')
   ```

2. Note that the output indicates the absence of 'strand' attributes for the genes.

## Observed Output
```bash
3.11.5 (main, Sep 27 2023, 11:42:37) [GCC 11.4.0]
CPython
Linux-5.15.0-91-generic-x86_64-with-glibc2.35
1.82
'strand' attribute does not exist for thrL in record U00096.3
'strand' attribute does not exist for thrA in record U00096.3
'strand' attribute does not exist for thrB in record U00096.3
'strand' attribute does not exist for thrC in record U00096.3
'strand' attribute does not exist for yaaX in record U00096.3
```


Discussion:
Change ``.strand`` to ``.location.strand`` when working with a ``SeqFeature`` under Biopython 1.82 (works on older versions too).

See #4555 (recent discussion) and #4503 (pull request where this change was made), which on re-reading we had explicitly deprecated strand etc as arguments to the SeqFeature initialization, but technically the properties of the same name were not officially deprecated. In hindsight I would have kept them for Biopython 1.82, but perhaps deprecated them.

Do you feel we should restore the ``.strand`` attribute? Would read only suffice? Would you object to restoring it just to deprecate it?
Thanks for the clarification regarding the `.strand` attribute in 1.82.

I believe adding an official note in the release about this change would be helpful for coders transitioning to the new version. It could probably prevent any confusion.

While I have already updated my code to use `.location.strand`, implementing a read-only `.strand` attribute or marking it as deprecated could be helpful to give some time to adapt to the new method while maintaining backward compatibility.

Again, thank you for addressing this issue and for your ongoing efforts to improve Biopython.
These sudden API breaking and undocumented changes make it disheartening to use Biopython for any kind of reliable, reproducible, and reusable tooling.  

Code will unexpectedly start breaking across minor releases like 1.81 to 1.82

And to support various versions one's code will be a kludge of monkey patches.

A similar undocumented API change occurred from 1.77 to 1.78 and now again from 1.81 to 1.82. I have grown to fear each new BioPython release ...

Think about it from a bigger perspective.  Existing codebases, pipelines, workflows, tutorials, teaching materials, slides, forum posts, advice and documentation become invalid with these changes. How is that worth it? It is as if you do not expect people to be using Biopython for anything other but quick hacks that are meant to break anyhow ...

You should never remove a documented API without some sort of documented deprecation methodology that notifies people using the code. People relying on Biopython shouldn't need to worry about new BioPython releases breaking their code. 

Resolving patch:
diff --git a/Bio/SeqFeature.py b/Bio/SeqFeature.py
--- a/Bio/SeqFeature.py
+++ b/Bio/SeqFeature.py
@@ -1,6 +1,6 @@
 # Copyright 2000-2003 Jeff Chang.
 # Copyright 2001-2008 Brad Chapman.
-# Copyright 2005-2016 by Peter Cock.
+# Copyright 2005-2024 by Peter Cock.
 # Copyright 2006-2009 Michiel de Hoon.
 # All rights reserved.
 #
@@ -65,6 +65,7 @@
 import warnings
 from abc import ABC, abstractmethod
 
+from Bio import BiopythonDeprecationWarning
 from Bio import BiopythonParserWarning
 from Bio.Seq import MutableSeq
 from Bio.Seq import reverse_complement
@@ -224,6 +225,92 @@ def __init__(
         if sub_features is not None:
             raise TypeError("Rather than sub_features, use a CompoundLocation")
 
+    def _get_strand(self):
+        """Get function for the strand property (PRIVATE)."""
+        warnings.warn(
+            "Please use .location.strand rather than .strand",
+            BiopythonDeprecationWarning,
+        )
+        return self.location.strand
+
+    def _set_strand(self, value):
+        """Set function for the strand property (PRIVATE)."""
+        warnings.warn(
+            "Please use .location.strand rather than .strand",
+            BiopythonDeprecationWarning,
+        )
+        try:
+            self.location.strand = value
+        except AttributeError:
+            if self.location is None:
+                if value is not None:
+                    raise ValueError("Can't set strand without a location.") from None
+            else:
+                raise
+
+    strand = property(
+        fget=_get_strand,
+        fset=_set_strand,
+        doc="Alias for the location's strand (DEPRECATED).",
+    )
+
+    def _get_ref(self):
+        """Get function for the reference property (PRIVATE)."""
+        warnings.warn(
+            "Please use .location.ref rather than .ref",
+            BiopythonDeprecationWarning,
+        )
+        try:
+            return self.location.ref
+        except AttributeError:
+            return None
+
+    def _set_ref(self, value):
+        """Set function for the reference property (PRIVATE)."""
+        warnings.warn(
+            "Please use .location.ref rather than .ref",
+            BiopythonDeprecationWarning,
+        )
+        try:
+            self.location.ref = value
+        except AttributeError:
+            if self.location is None:
+                if value is not None:
+                    raise ValueError("Can't set ref without a location.") from None
+            else:
+                raise
+
+    ref = property(
+        fget=_get_ref,
+        fset=_set_ref,
+        doc="Alias for the location's ref (DEPRECATED).",
+    )
+
+    def _get_ref_db(self):
+        """Get function for the database reference property (PRIVATE)."""
+        warnings.warn(
+            "Please use .location.ref_db rather than .ref_db",
+            BiopythonDeprecationWarning,
+        )
+        try:
+            return self.location.ref_db
+        except AttributeError:
+            return None
+
+    def _set_ref_db(self, value):
+        """Set function for the database reference property (PRIVATE)."""
+        warnings.warn(
+            "Please use .location.ref_db rather than .ref_db",
+            BiopythonDeprecationWarning,
+        )
+        self.location.ref_db = value
+
+    ref_db = property(
+        fget=_get_ref_db,
+        fset=_set_ref_db,
+        doc="Alias for the location's ref_db (DEPRECATED).",
+    )
+
     def __eq__(self, other):
         """Check if two SeqFeature objects should be considered equal."""
         return (


Test patch:
diff --git a/Tests/seq_tests_common.py b/Tests/seq_tests_common.py
--- a/Tests/seq_tests_common.py
+++ b/Tests/seq_tests_common.py
@@ -5,7 +5,9 @@
 """Common code for SeqRecord object tests."""
 
 import unittest
+import warnings
 
+from Bio import BiopythonDeprecationWarning
 from Bio.Seq import UndefinedSequenceError
 from Bio.SeqUtils.CheckSum import seguid
 from Bio.SeqFeature import ExactPosition, UnknownPosition
@@ -63,6 +65,12 @@ def compare_feature(self, old_f, new_f):
         self.assertEqual(old_f.location.strand, new_f.location.strand)
         self.assertEqual(old_f.location.ref, new_f.location.ref)
         self.assertEqual(old_f.location.ref_db, new_f.location.ref_db)
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore", category=BiopythonDeprecationWarning)
+            self.assertEqual(old_f.location.strand, new_f.strand)
+            self.assertEqual(old_f.location.ref, new_f.ref)
+            self.assertEqual(old_f.location.ref_db, new_f.ref_db)
+
         # TODO - BioSQL does not store/retrieve feature's id (Bug 2526)
         if new_f.id != "<unknown id>":
             self.assertEqual(old_f.id, new_f.id)
diff --git a/Tests/test_SeqFeature.py b/Tests/test_SeqFeature.py
--- a/Tests/test_SeqFeature.py
+++ b/Tests/test_SeqFeature.py
@@ -11,6 +11,7 @@
 from copy import deepcopy
 from os import path
 
+from Bio import BiopythonDeprecationWarning
 from Bio import Seq
 from Bio import SeqIO
 from Bio import SeqRecord
@@ -203,6 +204,19 @@ def test_translation_checks_cds(self):
         with self.assertRaises(TranslationError):
             f.translate(seq)
 
+    def test_location_aliases(self):
+        f = SeqFeature(None, type="CDS")
+        with warnings.catch_warnings(record=True) as w:
+            warnings.simplefilter("ignore", BiopythonDeprecationWarning)
+            with self.assertRaisesRegex(
+                AttributeError,
+                # "The .strand alias is only available when .location is defined.",
+                "'NoneType' object has no attribute 'strand'",
+            ):
+                f.strand
+            self.assertEqual(None, f.ref)
+            self.assertEqual(None, f.ref_db)
+
 
 class TestLocations(unittest.TestCase):
     def test_fuzzy(self):
diff --git a/Tests/test_SeqIO_features.py b/Tests/test_SeqIO_features.py
--- a/Tests/test_SeqIO_features.py
+++ b/Tests/test_SeqIO_features.py
@@ -9,9 +9,12 @@
 """
 import os
 import unittest
+import warnings
 
 from io import StringIO
 
+from Bio import BiopythonWarning
+from Bio import BiopythonDeprecationWarning
 from Bio import SeqIO
 from Bio.Data.CodonTable import TranslationError
 from Bio.Seq import MutableSeq
@@ -91,8 +94,15 @@ def compare_feature(self, old, new, msg=None):
         self.assertEqual(old.location.start, new.location.start, msg=msg)
         if old.location.strand is not None:
             self.assertEqual(old.location.strand, new.location.strand, msg=msg)
+            with warnings.catch_warnings():
+                warnings.simplefilter("ignore", category=BiopythonDeprecationWarning)
+                self.assertEqual(old.location.strand, new.strand)
         self.assertEqual(old.location.ref, new.location.ref, msg=msg)
         self.assertEqual(old.location.ref_db, new.location.ref_db, msg=msg)
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore", category=BiopythonDeprecationWarning)
+            self.assertEqual(old.location.ref, new.ref)
+            self.assertEqual(old.location.ref_db, new.ref_db)
         self.assertEqual(
             getattr(old.location, "operator", None),
             getattr(new.location, "operator", None),
@@ -1228,7 +1238,10 @@ def test_origin_line(self):
 
     def test_dbsource_wrap(self):
         """Write and read back dbsource_wrap.gb."""
-        self.write_read(os.path.join("GenBank", "dbsource_wrap.gb"), "gb", ["gb"])
+        with warnings.catch_warnings():
+            # Ignore warning about over long DBSOURCE line
+            warnings.simplefilter("ignore", category=BiopythonWarning)
+            self.write_read(os.path.join("GenBank", "dbsource_wrap.gb"), "gb", ["gb"])
         # Protein so can't convert this to EMBL format
 
     def test_blank_seq(self):