Repository: biopython/biopython

Issue:
Unclear alternative for obsolete code in `Bio.SeqFeature`
Some methods in `Bio.SeqFeature` are declared obsolete with a suggested alternative.
However, I don't see how the alternative could be applied without knowing the class.

As an example, the `position` attribute is declared obsolete. The alternatives depend on the class: 
```python
ExactPosition: int(self)
UnknownPosition: None
WithinPosition: self._left
BetweenPosition: self._left
BeforePosition: int(self)
AfterPosition: int(self)
OneOfPosition: min(int(pos) for pos in self.position_choices)
```

The `position` attribute is used, for example, in the `__init__`` function of `FeatureLocation`:
```python
        if (
            isinstance(self.start.position, int)
            and isinstance(self.end.position, int)
            and self.start > self.end
        ):
```

If the `position` attribute is obsolete, we should not be using it. But to replace it, we'd have to know the class, so you would have to replace
```python
start = self.start.position
```
by something like
```python
if isinstance(self.start, (ExactPosition, BeforePosition, AfterPosition)):
    start = int(self.start)
elif isinstance(self.start, (WithinPosition, BetweenPosition)):
    start = self._left
elif isinstance(self.start, UnknownPosition):
    start = None
elif isinstance(self.start, OneOfPosition):
    start = min(int(pos) for pos in self.position_choices)
```
which is not clean Python code.



Discussion:
In general use the object directly or wrap with ``int(...)``, almost all are subclasses of ``int``:

```
$ grep ^class SeqFeature.py 
class SeqFeature:
class Reference:
class FeatureLocation:
class CompoundLocation:
class AbstractPosition:
class ExactPosition(int, AbstractPosition):
class UncertainPosition(ExactPosition):
class UnknownPosition(AbstractPosition):
class WithinPosition(int, AbstractPosition):
class BetweenPosition(int, AbstractPosition):
class BeforePosition(int, AbstractPosition):
class AfterPosition(int, AbstractPosition):
class OneOfPosition(int, AbstractPosition):
class PositionGap:
```

Those positions which do not directly subclass ``int``,

* AbstractPosition - base class
* UncertainPosition - subclass of ExactPosition, and thus also of int
* UnknownPosition - cannot be cast into an int, could use NA as a float, but returning ``None`` was the historic position behaviour. Used for SwissProt / UniProt text files.
* PositionGap - unused, we should deprecate and remove that. It was added 21 years ago in 106be62b9f44c0640726492b645e2e10bb93e987 and last used in Biopython 1.55 in the GenBank parser. That usage was removed in 3e85df787b4968e284929ee9ca01bf3a9e87b41f (September 2021) when the old location parser location was removed in favour of the regular expression based code.

So, deprecate/remove ``PositionGap``, and we're left with a single exception - the ``UnknownPosition`` which cannot be treated as an integer.

And you're right the ``__init__`` method shouldn't be using ``.position`` anymore.

And we should formally deprecate the ``.position`` and ``.extension`` attributes now.
For `OneOfPosition`, `WithinPosition` and `BetweenPosition`, calling `int` on the object or using the `position` attribute will return a different number.

I think we should implement the rich comparison methods on `AbstactPosition`, overriding them in the derived classes when needed. Then we don't need to make use of `.position` in the `__init__` method of `FeatureLocation`.

Resolving patch:
diff --git a/Bio/SeqFeature.py b/Bio/SeqFeature.py
--- a/Bio/SeqFeature.py
+++ b/Bio/SeqFeature.py
@@ -1599,6 +1599,30 @@ def __repr__(self):
         """Represent the AbstractPosition object as a string for debugging."""
         return f"{self.__class__.__name__}(...)"
 
+    @property
+    def position(self):
+        """Legacy attribute to get (left-most) position as an integer (DEPRECATED)."""
+        warnings.warn(
+            "Alias location.position is deprecated and will be removed in a future "
+            "release. Use location directly, or int(location). However, that will "
+            "fail for UnknownPosition, and for OneOfPosition and WithinPosition "
+            "will give the default rather than left-most value.",
+            BiopythonDeprecationWarning,
+        )
+        return int(self)
+
+    @property
+    def extension(self):
+        """Legacy attribute to get the position's 'width' as an integer, typically zero (DEPRECATED)."""
+        warnings.warn(
+            "Alias location.extension is deprecated and will be removed in a "
+            "future release. It was undefined or zero except for OneOfPosition, "
+            "WithinPosition and WithinPosition which must now be handled "
+            "explicitly instead.",
+            BiopythonDeprecationWarning,
+        )
+        return 0
+
 
 class ExactPosition(int, AbstractPosition):
     """Specify the specific position of a boundary.
@@ -1650,16 +1674,6 @@ def __repr__(self):
         """Represent the ExactPosition object as a string for debugging."""
         return "%s(%i)" % (self.__class__.__name__, int(self))
 
-    @property
-    def position(self):
-        """Legacy attribute to get position as integer (OBSOLETE)."""
-        return int(self)
-
-    @property
-    def extension(self):
-        """Not present in this object, return zero (OBSOLETE)."""
-        return 0
-
     def _shift(self, offset):
         """Return a copy of the position object with its location shifted (PRIVATE)."""
         # By default preserve any subclass
@@ -1697,13 +1711,26 @@ def __hash__(self):
 
     @property
     def position(self):
-        """Legacy attribute to get location (None) (OBSOLETE)."""
-        return None
+        """Legacy attribute to get location (None) (DEPRECATED).
 
-    @property
-    def extension(self):  # noqa: D402
-        """Legacy attribute to get extension (zero) as integer (OBSOLETE)."""  # noqa: D402
-        return 0
+        In general you can use the location directly as with the exception of
+        UnknownPosition it subclasses int, or use int(location), rather than
+        this location.position legacy attribute.
+
+        However, the UnknownPosition cannot be cast to an integer, and thus
+        does not subclass int, and int(...) will fail. The legacy attribute
+        would return None instead.
+
+        Note that while None == None, UnknownPosition() != UnknownPosition()
+        which is like the behavour for NaN.
+        """
+        warnings.warn(
+            "Alias location.position is deprecated and will be removed in a future release. "
+            "In general use position directly, but not note for UnknownPosition "
+            "int(location) will fail. Use try/except or isinstance(location, UnknownPosition).",
+            BiopythonDeprecationWarning,
+        )
+        return None
 
     def _shift(self, offset):
         """Return a copy of the position object with its location shifted (PRIVATE)."""
@@ -1722,10 +1749,10 @@ class WithinPosition(int, AbstractPosition):
     - left - The start (left) position of the boundary
     - right - The end (right) position of the boundary
 
-    This allows dealing with a location like ((1.4)..100). This
-    indicates that the start of the sequence is somewhere between 1
-    and 4. Since this is a start coordinate, it should acts like
-    it is at position 1 (or in Python counting, 0).
+    This allows dealing with a location like ((11.14)..100). This
+    indicates that the start of the sequence is somewhere between 11
+    and 14. Since this is a start coordinate, it should act like
+    it is at position 11 (or in Python counting, 10).
 
     >>> p = WithinPosition(10, 10, 13)
     >>> p
@@ -1766,7 +1793,8 @@ class WithinPosition(int, AbstractPosition):
     >>> p == AfterPosition(10)
     True
 
-    If this were an end point, you would want the position to be 13:
+    If this were an end point, you would want the position to be 13
+    (the right/larger value, not the left/smaller value as above):
 
     >>> p2 = WithinPosition(13, 10, 13)
     >>> p2
@@ -1780,23 +1808,6 @@ class WithinPosition(int, AbstractPosition):
     >>> p2 == ExactPosition(13)
     True
 
-    The old legacy properties of position and extension give the
-    starting/lower/left position as an integer, and the distance
-    to the ending/higher/right position as an integer. Note that
-    the position object will act like either the left or the right
-    end-point depending on how it was created:
-
-    >>> p.position == p2.position == 10
-    True
-    >>> p.extension == p2.extension == 3
-    True
-    >>> int(p) == int(p2)
-    False
-    >>> p == 10
-    True
-    >>> p2 == 13
-    True
-
     """
 
     def __new__(cls, position, left, right):
@@ -1833,12 +1844,23 @@ def __str__(self):
 
     @property
     def position(self):
-        """Legacy attribute to get (left) position as integer (OBSOLETE)."""
+        """Legacy attribute to get (left) position as integer (DEPRECATED)."""
+        warnings.warn(
+            "Alias location.position is deprecated and will be removed in a future release. "
+            "Use location directly, or int(location) which will return the preferred location "
+            "defined for WithinPosition (which may not be the left-most position).",
+            BiopythonDeprecationWarning,
+        )
         return self._left
 
     @property
-    def extension(self):  # noqa: D402
-        """Legacy attribute to get extension (from left to right) as an integer (OBSOLETE)."""  # noqa: D402
+    def extension(self):
+        """Legacy attribute to get the within-position's 'width' as an integer (DEPRECATED)."""
+        warnings.warn(
+            "Alias location.extension is deprecated and will be removed in a future release. "
+            "This is usually zero, but there is no neat replacement for the WithinPosition object.",
+            BiopythonDeprecationWarning,
+        )
         return self._right - self._left
 
     def _shift(self, offset):
@@ -1892,14 +1914,6 @@ class BetweenPosition(int, AbstractPosition):
     end-point depending on how it was created:
 
     >>> p2 = BetweenPosition(123, left=123, right=456)
-    >>> p.position == p2.position == 123
-    True
-    >>> p.extension
-    333
-    >>> p2.extension
-    333
-    >>> p.extension == p2.extension == 333
-    True
     >>> int(p) == int(p2)
     False
     >>> p == 456
@@ -1924,6 +1938,7 @@ class BetweenPosition(int, AbstractPosition):
     def __new__(cls, position, left, right):
         """Create a new instance in BetweenPosition object."""
         assert position == left or position == right
+        # TODO - public API for getting left/right, especially the unknown one
         obj = int.__new__(cls, position)
         obj._left = left
         obj._right = right
@@ -1951,12 +1966,23 @@ def __str__(self):
 
     @property
     def position(self):
-        """Legacy attribute to get (left) position as integer (OBSOLETE)."""
+        """Legacy attribute to get (left) position as integer (DEPRECATED)."""
+        warnings.warn(
+            "Alias location.position is deprecated and will be removed in a future release. "
+            "Use location directly, or int(location) which will return the preferred location "
+            "defined for a BetweenPosition (which may not be the left-most position).",
+            BiopythonDeprecationWarning,
+        )
         return self._left
 
     @property
-    def extension(self):  # noqa: D402
-        """Legacy attribute to get extension (from left to right) as an integer (OBSOLETE)."""  # noqa: D402
+    def extension(self):
+        """Legacy attribute to get the between-position's 'width' as an integer (DEPRECATED)."""
+        warnings.warn(
+            "Alias location.extension is deprecated and will be removed in a future release. "
+            "This is usually zero, but there is no neat replacement for the BetweenPosition object.",
+            BiopythonDeprecationWarning,
+        )
         return self._right - self._left
 
     def _shift(self, offset):
@@ -2012,23 +2038,13 @@ def __new__(cls, position, extension=0):
             raise AttributeError(f"Non-zero extension {extension} for exact position.")
         return int.__new__(cls, position)
 
-    @property
-    def position(self):
-        """Legacy attribute to get position as integer (OBSOLETE)."""
-        return int(self)
-
-    @property
-    def extension(self):  # noqa: D402
-        """Legacy attribute to get extension (zero) as integer (OBSOLETE)."""  # noqa: D402
-        return 0
-
     def __repr__(self):
         """Represent the location as a string for debugging."""
         return "%s(%i)" % (self.__class__.__name__, int(self))
 
     def __str__(self):
         """Return a representation of the BeforePosition object (with python counting)."""
-        return f"<{self.position}"
+        return f"<{int(self)}"
 
     def _shift(self, offset):
         """Return a copy of the position object with its location shifted (PRIVATE)."""
@@ -2086,23 +2102,13 @@ def __new__(cls, position, extension=0):
             raise AttributeError(f"Non-zero extension {extension} for exact position.")
         return int.__new__(cls, position)
 
-    @property
-    def position(self):
-        """Legacy attribute to get position as integer (OBSOLETE)."""
-        return int(self)
-
-    @property
-    def extension(self):  # noqa: D402
-        """Legacy attribute to get extension (zero) as integer (OBSOLETE)."""  # noqa: D402
-        return 0
-
     def __repr__(self):
         """Represent the location as a string for debugging."""
         return "%s(%i)" % (self.__class__.__name__, int(self))
 
     def __str__(self):
         """Return a representation of the AfterPosition object (with python counting)."""
-        return f">{self.position}"
+        return f">{int(self)}"
 
     def _shift(self, offset):
         """Return a copy of the position object with its location shifted (PRIVATE)."""
@@ -2144,24 +2150,6 @@ class OneOfPosition(int, AbstractPosition):
     >>> isinstance(p, int)
     True
 
-    The old legacy properties of position and extension give the
-    starting/lowest/left-most position as an integer, and the
-    distance to the ending/highest/right-most position as an integer.
-    Note that the position object will act like one of the list of
-    possible locations depending on how it was created:
-
-    >>> p2 = OneOfPosition(1901, [ExactPosition(1888), ExactPosition(1901)])
-    >>> p.position == p2.position == 1888
-    True
-    >>> p.extension == p2.extension == 13
-    True
-    >>> int(p) == int(p2)
-    False
-    >>> p == 1888
-    True
-    >>> p2 == 1901
-    True
-
     """
 
     def __new__(cls, position, choices):
@@ -2189,12 +2177,25 @@ def __getnewargs__(self):
 
     @property
     def position(self):
-        """Legacy attribute to get (left) position as integer (OBSOLETE)."""
+        """Legacy attribute to get (left) position as integer (DEPRECATED)."""
+        warnings.warn(
+            "Alias location.position is deprecated and will be removed in a future release. "
+            "Use location directly, or int(location) which will return the preferred location "
+            "defined for a OneOfPosition (which may not be the left-most position), or "
+            "min(location.position_choices) instead.",
+            BiopythonDeprecationWarning,
+        )
         return min(int(pos) for pos in self.position_choices)
 
     @property
     def extension(self):
-        """Legacy attribute to get extension as integer (OBSOLETE)."""
+        """Legacy attribute to get the one-of-position's 'width' as an integer (DEPRECATED)."""
+        warnings.warn(
+            "Alias location.extension is deprecated and will be removed in a future release. "
+            "This is usually zero, but for a OneOfPosition you can use "
+            "max(position.position_choices) - min(position.position_choices)",
+            BiopythonDeprecationWarning,
+        )
         positions = [int(pos) for pos in self.position_choices]
         return max(positions) - min(positions)
 
diff --git a/Bio/SeqIO/InsdcIO.py b/Bio/SeqIO/InsdcIO.py
--- a/Bio/SeqIO/InsdcIO.py
+++ b/Bio/SeqIO/InsdcIO.py
@@ -222,21 +222,23 @@ def _insdc_feature_position_string(pos, offset=0):
     Use offset=1 to add one to convert a start position from python counting.
     """
     if isinstance(pos, SeqFeature.ExactPosition):
-        return "%i" % (pos.position + offset)
+        return "%i" % (pos + offset)
     elif isinstance(pos, SeqFeature.WithinPosition):
+        # TODO - avoid private variables
         return "(%i.%i)" % (
-            pos.position + offset,
-            pos.position + pos.extension + offset,
+            pos._left + offset,
+            pos._right + offset,
         )
     elif isinstance(pos, SeqFeature.BetweenPosition):
+        # TODO - avoid private variables
         return "(%i^%i)" % (
-            pos.position + offset,
-            pos.position + pos.extension + offset,
+            pos._left + offset,
+            pos._right + offset,
         )
     elif isinstance(pos, SeqFeature.BeforePosition):
-        return "<%i" % (pos.position + offset)
+        return "<%i" % (pos + offset)
     elif isinstance(pos, SeqFeature.AfterPosition):
-        return ">%i" % (pos.position + offset)
+        return ">%i" % (pos + offset)
     elif isinstance(pos, SeqFeature.OneOfPosition):
         return "one-of(%s)" % ",".join(
             _insdc_feature_position_string(p, offset) for p in pos.position_choices
@@ -256,25 +258,25 @@ def _insdc_location_string_ignoring_strand_and_subfeatures(location, rec_length)
     if (
         isinstance(location.start, SeqFeature.ExactPosition)
         and isinstance(location.end, SeqFeature.ExactPosition)
-        and location.start.position == location.end.position
+        and location.start == location.end
     ):
         # Special case, for 12:12 return 12^13
         # (a zero length slice, meaning the point between two letters)
-        if location.end.position == rec_length:
+        if location.end == rec_length:
             # Very special case, for a between position at the end of a
             # sequence (used on some circular genomes, Bug 3098) we have
             # N:N so return N^1
             return "%s%i^1" % (ref, rec_length)
         else:
-            return "%s%i^%i" % (ref, location.end.position, location.end.position + 1)
+            return "%s%i^%i" % (ref, location.end, location.end + 1)
     if (
         isinstance(location.start, SeqFeature.ExactPosition)
         and isinstance(location.end, SeqFeature.ExactPosition)
-        and location.start.position + 1 == location.end.position
+        and location.start + 1 == location.end
     ):
         # Special case, for 11:12 return 12 rather than 12..12
         # (a length one slice, meaning a single letter)
-        return "%s%i" % (ref, location.end.position)
+        return "%s%i" % (ref, location.end)
     elif isinstance(location.start, SeqFeature.UnknownPosition) or isinstance(
         location.end, SeqFeature.UnknownPosition
     ):
diff --git a/Bio/SeqIO/XdnaIO.py b/Bio/SeqIO/XdnaIO.py
--- a/Bio/SeqIO/XdnaIO.py
+++ b/Bio/SeqIO/XdnaIO.py
@@ -338,8 +338,8 @@ def write_file(self, records):
 
             self._write_pstring(feature.type)
 
-            start = feature.location.start.position + 1  # 1-based coordinates
-            end = feature.location.end.position
+            start = int(feature.location.start) + 1  # 1-based coordinates
+            end = int(feature.location.end)
             strand = 1
             if feature.location.strand == -1:
                 start, end = end, start


Test patch:
diff --git a/Tests/test_GenomeDiagram.py b/Tests/test_GenomeDiagram.py
--- a/Tests/test_GenomeDiagram.py
+++ b/Tests/test_GenomeDiagram.py
@@ -829,10 +829,11 @@ def test_partial_diagram(self):
             if feature.type != "CDS":
                 # We're going to ignore these.
                 continue
-            if feature.location.end.position < start:
+            # These may miss fuzzy locations where the integer sorting is a simplification
+            if feature.location.end < start:
                 # Out of frame (too far left)
                 continue
-            if feature.location.start.position > end:
+            if feature.location.start > end:
                 # Out of frame (too far right)
                 continue
 
diff --git a/Tests/test_SeqUtils.py b/Tests/test_SeqUtils.py
--- a/Tests/test_SeqUtils.py
+++ b/Tests/test_SeqUtils.py
@@ -58,8 +58,8 @@ def test_codon_usage_custom(self):
         records = []
         for feature in record.features:
             if feature.type == "CDS" and len(feature.location.parts) == 1:
-                start = feature.location.start.position
-                end = feature.location.end.position
+                start = feature.location.start
+                end = feature.location.end
                 table = int(feature.qualifiers["transl_table"][0])
                 if feature.strand == -1:
                     seq = record.seq[start:end].reverse_complement()