Repository: cogent3/ensembl_tui
Pull request #200: ENH: include exon phase in cds spans
Author: login: GavinHuttley
Created: 2025-03-06T23:57:39Z | Merged: 2025-03-07T01:03:41Z

[NEW] now capture exon phase and end_phase in the exon_view table,
     these are used to adjust the rel_start and rel_end values to
     the limiting exons to get the correct cds spans

[CHANGED] modified tests to include these new columns

Notes: it seems rel_start, which derives from translation.seq_start is not always correct for starting exons with rank > 1. It may be that when rel_start is 0 the phase can be > 0, at which point we need to compensate. If I can confirmt this is correct, the LimitingExon code and attributes can be simplified.

## Summary by Sourcery

Tests:
- Add new columns to tests to include phase and end_phase.


Diff:
diff --git a/src/ensembl_tui/_mysql_core_attr.py b/src/ensembl_tui/_mysql_core_attr.py
index 865e625e..d0f1e125 100644
--- a/src/ensembl_tui/_mysql_core_attr.py
+++ b/src/ensembl_tui/_mysql_core_attr.py
@@ -130,11 +130,29 @@ class LimitExons:
     rel_stop: int
     strand: int
     transcript_id: int
+    phase: int = dataclasses.field(init=False, default=-1)
+    end_phase: int = dataclasses.field(init=False, default=-1)
 
     @property
     def single_exon(self) -> bool:
         return self.start_rank == self.stop_rank
 
+    def set_phase_values(self, phase: int, end_phase: int) -> None:
+        if self.rel_start == 0 and phase > 0:
+            msg = f"WARNING: phase is {phase} but rel_start==0 for {self.transcript_id}"
+            eti_util.print_colour(msg, colour="red")
+
+        self.phase = phase
+        self.end_phase = end_phase
+
+    @property
+    def new_start(self) -> int:
+        return self.rel_start if self.phase <= 0 else self.rel_start + 3 - self.phase
+
+    @property
+    def new_stop(self) -> int:
+        return self.rel_stop if self.end_phase <= 0 else self.rel_stop - self.end_phase
+
 
 def get_all_limit_exons(
     conn: duckdb.DuckDBPyConnection,
@@ -148,7 +166,7 @@ def get_all_limit_exons(
         ev.rank,
         t.seq_start,
         t.seq_end,
-        ev.strand
+        ev.strand,
     FROM exon_view ev
     JOIN translation t ON ev.transcript_id = t.transcript_id
     AND (ev.exon_id = t.start_exon_id OR ev.exon_id = t.end_exon_id);
@@ -236,8 +254,8 @@ def to_record(self, columns: tuple[str, ...]) -> tuple:
 def _adjust_single_exon(lex: LimitExons, cds_span: tuple[int, int]) -> tuple[int, int]:
     ex_start = cds_span[0] if lex.strand == 1 else cds_span[1]
     if lex.strand == 1:
-        return ex_start + lex.rel_start, ex_start + lex.rel_stop
-    return ex_start - lex.rel_stop, ex_start - lex.rel_start
+        return ex_start + lex.new_start, ex_start + lex.new_stop
+    return ex_start - lex.new_stop, ex_start - lex.new_start
 
 
 def get_transcript_attr_records(
@@ -250,7 +268,9 @@ def get_transcript_attr_records(
     transcript_id, gene_id, strand, seqid, transcript_stable_id, cds_stable_id,
     STRING_AGG(CAST(start AS VARCHAR), ' ') AS agg_start,
     STRING_AGG(CAST(stop AS VARCHAR), ' ') AS agg_stop,
-    STRING_AGG(CAST(rank AS VARCHAR), ' ') AS agg_rank
+    STRING_AGG(CAST(rank AS VARCHAR), ' ') AS agg_rank,
+    STRING_AGG(CAST(phase AS VARCHAR), ' ') AS agg_phase,
+    STRING_AGG(CAST(end_phase AS VARCHAR), ' ') AS agg_end_phase
     FROM exon_view
     GROUP BY transcript_id, gene_id, strand, seqid, transcript_stable_id, cds_stable_id
     """
@@ -265,17 +285,23 @@ def get_transcript_attr_records(
         agg_start,
         agg_stop,
         agg_rank,
+        agg_phase,
+        agg_end_phase,
     ) in conn.sql(sql).fetchall():
         # Note that the adjustment of start to be 0-based has already been
         # done during the mysqldump import
         starts = numpy.fromstring(agg_start, sep=" ", dtype=numpy.int32)
         stops = numpy.fromstring(agg_stop, sep=" ", dtype=numpy.int32)
         ranks = numpy.fromstring(agg_rank, sep=" ", dtype=numpy.int32)
+        phases = numpy.fromstring(agg_phase, sep=" ", dtype=numpy.int32)
+        end_phases = numpy.fromstring(agg_end_phase, sep=" ", dtype=numpy.int32)
         # make the transcript exon spans, in rank order
         # to facilitate getting the cds spans
         transcript_spans = numpy.empty((starts.size, 2), dtype=numpy.int32)
+        exon_phases = numpy.empty((starts.size, 2), dtype=numpy.int32)
         for i, rank in enumerate(ranks):
             transcript_spans[rank - 1] = (starts[i], stops[i])
+            exon_phases[rank - 1] = phases[i], end_phases[i]
 
         cds_spans = transcript_spans.copy()
         transcript_spans = transcript_spans[numpy.lexsort(transcript_spans.T), :]
@@ -296,6 +322,7 @@ def get_transcript_attr_records(
         lex = get_limit_exons(limit_exons[transcript_id])
         start_index = lex.start_rank - 1
         stop_index = lex.stop_rank - 1
+        lex.set_phase_values(exon_phases[start_index, 0], exon_phases[stop_index, 1])
         # adjust the start and end using the limiting exons
         # the rel_start and rel_stop are BOTH relative to the
         # 5' end of an exon
@@ -321,20 +348,20 @@ def get_transcript_attr_records(
         stop_exon_coords = cds_spans[stop_index]
         if lex.strand == 1:
             start_exon_coords = (
-                start_exon_coords[0] + lex.rel_start,
+                start_exon_coords[0] + lex.new_start,
                 start_exon_coords[1],
             )
             stop_exon_coords = (
                 stop_exon_coords[0],
-                stop_exon_coords[0] + lex.rel_stop,
+                stop_exon_coords[0] + lex.new_stop,
             )
         else:
             start_exon_coords = (
                 start_exon_coords[0],
-                start_exon_coords[1] - lex.rel_start,
+                start_exon_coords[1] - lex.new_start,
             )
             stop_exon_coords = (
-                stop_exon_coords[1] - lex.rel_stop,
+                stop_exon_coords[1] - lex.new_stop,
                 stop_exon_coords[1],
             )
 
@@ -371,6 +398,8 @@ def make_transcript_attr(con: duckdb.DuckDBPyConnection) -> duckdb.DuckDBPyConne
             ex.seq_region_start AS start,
             ex.seq_region_end AS stop,
             ex.seq_region_strand AS strand,
+            ex.phase AS phase,
+            ex.end_phase AS end_phase,
             et.rank AS rank,
             tr.gene_id as gene_id,
             tr.stable_id as transcript_stable_id,
diff --git a/tests/test_cli.py b/tests/test_cli.py
index bf99d847..b34959ab 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -53,7 +53,7 @@ def test_exportrc(tmp_dir):
 
 @pytest.fixture(scope="module")
 def installed(tmp_downloaded):
-    # tmp_downloaded is a temp copy of the doanload folder
+    # tmp_downloaded is a temp copy of the download folder
     # we add the verbose and force_overwrite flags to exercise
     # those conditional statements
     r = RUNNER.invoke(
diff --git a/tests/test_install.py b/tests/test_install.py
index 036e61a9..36bf1cd0 100644
--- a/tests/test_install.py
+++ b/tests/test_install.py
@@ -31,6 +31,8 @@
     "gene_id INTEGER",
     "transcript_stable_id TEXT",
     "cds_stable_id TEXT",
+    "phase TINYINT",
+    "end_phase TINYINT",
 )
 
 EXON_VIEW_COLS = [c.split()[0] for c in EXON_VIEW_SCHEMA]
@@ -49,6 +51,8 @@
     "seq_region_start INTEGER",
     "seq_region_end INTEGER",
     "seq_region_strand INTEGER",
+    "phase TINYINT",
+    "end_phase TINYINT",
 )
 
 EXON_COLS = [c.split()[0] for c in EXON_SCHEMA]
@@ -288,6 +292,8 @@ def four_exons(empty_ev_tr):
             "gene_id": 42,
             "transcript_stable_id": "a1",
             "cds_stable_id": "a1",
+            "phase": -1,
+            "end_phase": -1,
         },
         {
             "transcript_id": 11,
@@ -300,6 +306,8 @@ def four_exons(empty_ev_tr):
             "gene_id": 42,
             "transcript_stable_id": "a1",
             "cds_stable_id": "a1",
+            "phase": -1,
+            "end_phase": -1,
         },
         {
             "transcript_id": 11,
@@ -312,6 +320,8 @@ def four_exons(empty_ev_tr):
             "gene_id": 42,
             "transcript_stable_id": "a1",
             "cds_stable_id": "a1",
+            "phase": -1,
+            "end_phase": -1,
         },
         {
             "transcript_id": 11,
@@ -324,6 +334,8 @@ def four_exons(empty_ev_tr):
             "gene_id": 42,
             "transcript_stable_id": "a1",
             "cds_stable_id": "a1",
+            "phase": -1,
+            "end_phase": -1,
         },
     ]
     value_placeholder = "?, " * len(EXON_VIEW_COLS)
@@ -437,6 +449,8 @@ def two_exons_minus_strand(empty_ev_tr):
             "gene_id": 42,
             "transcript_stable_id": "a1",
             "cds_stable_id": "a1",
+            "phase": -1,
+            "end_phase": -1,
         },
         {
             "transcript_id": 11,
@@ -449,10 +463,14 @@ def two_exons_minus_strand(empty_ev_tr):
             "gene_id": 42,
             "transcript_stable_id": "a1",
             "cds_stable_id": "a1",
+            "phase": -1,
+            "end_phase": -1,
         },
     ]
+    value_placeholder = "?, " * len(EXON_VIEW_COLS)
+
     conn.executemany(
-        "INSERT INTO exon_view VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+        f"INSERT INTO exon_view VALUES ({value_placeholder})",
         [[r[c] for c in EXON_VIEW_COLS] for r in exon_view_data],
     )
     return conn
@@ -529,6 +547,8 @@ def mixed_data():
             "seq_region_start": 100,
             "seq_region_end": 200,
             "seq_region_strand": 1,
+            "phase": -1,
+            "end_phase": -1,
         },
         {
             "exon_id": 2,
@@ -537,6 +557,8 @@ def mixed_data():
             "seq_region_start": 300,
             "seq_region_end": 400,
             "seq_region_strand": 1,
+            "phase": -1,
+            "end_phase": -1,
         },
         {
             "exon_id": 3,
@@ -545,6 +567,8 @@ def mixed_data():
             "seq_region_start": 500,
             "seq_region_end": 600,
             "seq_region_strand": 1,
+            "phase": -1,
+            "end_phase": -1,
         },
         {
             "exon_id": 4,
@@ -553,6 +577,8 @@ def mixed_data():
             "seq_region_start": 700,
             "seq_region_end": 800,
             "seq_region_strand": 1,
+            "phase": -1,
+            "end_phase": -1,
         },
         {
             "exon_id": 5,
@@ -561,6 +587,8 @@ def mixed_data():
             "seq_region_start": 900,
             "seq_region_end": 1000,
             "seq_region_strand": -1,
+            "phase": -1,
+            "end_phase": -1,
         },
         {
             "exon_id": 6,
@@ -569,6 +597,8 @@ def mixed_data():
             "seq_region_start": 1100,
             "seq_region_end": 1200,
             "seq_region_strand": -1,
+            "phase": -1,
+            "end_phase": -1,
         },
         {
             "exon_id": 7,
@@ -577,10 +607,14 @@ def mixed_data():
             "seq_region_start": 1300,
             "seq_region_end": 1400,
             "seq_region_strand": -1,
+            "phase": -1,
+            "end_phase": -1,
         },
     ]
+    value_placeholder = "?, " * len(EXON_COLS)
+
     conn.executemany(
-        "INSERT INTO exon VALUES (?, ?, ?, ?, ?, ?)",
+        f"INSERT INTO exon VALUES ({value_placeholder})",
         parameters=[[r[c] for c in EXON_COLS] for r in exon_data],
     )
     et_data = [


Reviews:
user: login: sourcery-ai[bot]
body: Hey @GavinHuttley - I've reviewed your changes - here's some feedback:

**Overall Comments**:
- Consider adding a method to the LimitExons class to encapsulate the logic for determining `new_start` and `new_stop` based on strand.
- It might be clearer to calculate `exon_phases` in the same loop where `transcript_spans` is populated.

<details>
<summary>Here's what I looked at during the review</summary>

- 🟢 **General issues**: all looks good
- 🟢 **Security**: all looks good
- 🟡 **Testing**: 1 issue found
- 🟡 **Complexity**: 1 issue found
- 🟢 **Documentation**: all looks good
</details>

***

<details>
<summary>Sourcery is free for open source - if you like our reviews please consider sharing them ✨</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://sourcery.ai)
- [Facebook](https://www.facebook.com/sharer/sharer.php?u=https://sourcery.ai)

</details>

<sub>
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
</sub>
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-06T23:58:47Z
user: login: GavinHuttley
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-07T00:40:10Z
user: login: GavinHuttley
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-07T00:40:31Z
user: login: sourcery-ai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-07T00:40:39Z

Review comments:
diff_hunk: @@ -288,6 +292,8 @@ def four_exons(empty_ev_tr):
             "gene_id": 42,
             "transcript_stable_id": "a1",
             "cds_stable_id": "a1",
+            "phase": -1,
+            "end_phase": -1,
path: tests/test_install.py
user: login: sourcery-ai[bot]
body: **suggestion (testing):** Add tests for non-negative phase values.

The description mentions the possibility of `rel_start` being 0 while `phase` is > 0.  It's important to test these cases to ensure the `set_phase_values` and subsequent calculations in `_adjust_single_exon` handle this correctly.  Consider adding tests with `phase` > 0 and `rel_start` == 0, as well as other non-negative phase values.

Suggested implementation:

```python
import unittest
from your_module import set_phase_values, _adjust_single_exon  # Adjust the module path as needed

class TestExonPhaseValues(unittest.TestCase):
    def test_non_negative_phase_with_rel_start_zero(self):
        # Test where rel_start is 0 and phase > 0
        exon = {
            "transcript_id": 12,
            "gene_id": 42,
            "transcript_stable_id": "a1",
            "cds_stable_id": "a1",
            "phase": 1,        # non-negative phase > 0
            "end_phase": 0,
            "rel_start": 0,    # starting at 0
        }
        adjusted = set_phase_values(exon.copy())
        self.assertGreaterEqual(adjusted["phase"], 0, "Phase should be non-negative after set_phase_values")
        final_exon = _adjust_single_exon(adjusted.copy())
        self.assertGreaterEqual(final_exon["phase"], 0, "Phase should be non-negative after _adjust_single_exon")

    def test_non_negative_phase_with_other_values(self):
        # Test with other non-negative phase values and a rel_start != 0
        exon = {
            "transcript_id": 13,
            "gene_id": 43,
            "transcript_stable_id": "a2",
            "cds_stable_id": "a2",
            "phase": 2,        # non-negative phase
            "end_phase": 1,
            "rel_start": 5,
        }
        adjusted = set_phase_values(exon.copy())
        self.assertGreaterEqual(adjusted["phase"], 0, "Phase should be non-negative after set_phase_values")
        final_exon = _adjust_single_exon(adjusted.copy())
        self.assertGreaterEqual(final_exon["phase"], 0, "Phase should be non-negative after _adjust_single_exon")

if __name__ == '__main__':
    unittest.main()

```

Adjust the import statement "from your_module import ..." to point to the correct module where set_phase_values and _adjust_single_exon are defined in your codebase.
created_at: 2025-03-06T23:58:47Z
author_association: NONE
diff_hunk: @@ -236,8 +254,8 @@ def to_record(self, columns: tuple[str, ...]) -> tuple:
 def _adjust_single_exon(lex: LimitExons, cds_span: tuple[int, int]) -> tuple[int, int]:
path: src/ensembl_tui/_mysql_core_attr.py
user: login: sourcery-ai[bot]
body: **issue (complexity):** Consider centralizing the phase-based coordinate adjustments into a single helper method on the LimitExons class to reduce code duplication and improve maintainability by calling this method from the relevant locations, such as in `_adjust_single_exon` function and other similar adjustment logic sections .

Consider centralizing the phase‐based coordinate adjustments. Currently, the calculations for adjusted start/stop positions are repeated in multiple locations. A single helper method on the LimitExons class can encapsulate this logic and reduce duplication. For example:

```python
@dataclasses.dataclass(slots=True)
class LimitExons:
    # existing fields ...
    phase: int = dataclasses.field(init=False, default=-1)
    end_phase: int = dataclasses.field(init=False, default=-1)
    # existing properties and methods ...

    @property
    def new_start(self) -> int:
        return self.rel_start if self.phase <= 0 else self.rel_start + 3 - self.phase

    @property
    def new_stop(self) -> int:
        return self.rel_stop if self.end_phase <= 0 else self.rel_stop - self.end_phase

    def adjusted_coords(self, exon_coords: tuple[int, int]) -> tuple[int, int]:
        """Centralizes exon coordinate adjustment using new_start/new_stop."""
        if self.strand == 1:
            return (exon_coords[0] + self.new_start, exon_coords[0] + self.new_stop)
        else:
            return (exon_coords[1] - self.new_stop, exon_coords[1] - self.new_start)
```

Then simplify the related parts of your code. For example, you can refactor `_adjust_single_exon` as:

```python
def _adjust_single_exon(lex: LimitExons, cds_span: tuple[int, int]) -> tuple[int, int]:
    return lex.adjusted_coords(cds_span)
```

And replace similar adjustment logic elsewhere with `lex.adjusted_coords(...)`. This consolidation reduces nesting and eases maintenance while keeping functionality intact.
created_at: 2025-03-06T23:58:47Z
author_association: NONE
diff_hunk: @@ -288,6 +292,8 @@ def four_exons(empty_ev_tr):
             "gene_id": 42,
             "transcript_stable_id": "a1",
             "cds_stable_id": "a1",
+            "phase": -1,
+            "end_phase": -1,
path: tests/test_install.py
user: login: GavinHuttley
body: we don't use unittest
created_at: 2025-03-07T00:40:10Z
author_association: NONE
diff_hunk: @@ -288,6 +292,8 @@ def four_exons(empty_ev_tr):
             "gene_id": 42,
             "transcript_stable_id": "a1",
             "cds_stable_id": "a1",
+            "phase": -1,
+            "end_phase": -1,
path: tests/test_install.py
user: login: GavinHuttley
body: those other phase values are invalid
created_at: 2025-03-07T00:40:31Z
author_association: NONE
diff_hunk: @@ -288,6 +292,8 @@ def four_exons(empty_ev_tr):
             "gene_id": 42,
             "transcript_stable_id": "a1",
             "cds_stable_id": "a1",
+            "phase": -1,
+            "end_phase": -1,
path: tests/test_install.py
user: login: sourcery-ai[bot]
body: Okay, I understand. It seems my suggestion to use `unittest` and include arbitrary non-negative phase values was not appropriate for this project.

Regarding the use of `unittest`, I apologize for the oversight. I should have considered the existing testing framework.

Regarding the invalid phase values, thanks for pointing that out. I was trying to be comprehensive but clearly missed the constraints on those values.

To address the core concern of testing the scenario where `rel_start` is 0 and `phase` > 0, and to align with the existing testing framework, could you provide a snippet of how the tests are currently structured?  Knowing that will help me suggest a more appropriate test case.

In the meantime, consider adapting one of the existing test functions (like `four_exons`) to include a case where `rel_start` is 0 and `phase` is a valid positive value (likely 1 or 2, depending on the context).  The key is to ensure that `set_phase_values` and `_adjust_single_exon` (or their equivalents in your actual code) correctly handle this specific scenario.

created_at: 2025-03-07T00:40:39Z
author_association: NONE

PR comments:
user: login: sourcery-ai[bot]
created_at: 2025-03-06T23:57:43Z
body: <!-- Generated by sourcery-ai[bot]: start review_guide -->

## Reviewer's Guide by Sourcery

This pull request introduces exon phase and end_phase information into the exon_view table. This information is then used to adjust the relative start and end positions of exons, allowing for more accurate determination of CDS spans. The tests have been modified to include these new columns.

#### Sequence diagram for CDS span adjustment

```mermaid
sequenceDiagram
  participant get_transcript_attr_records
  participant get_limit_exons
  participant LimitExons
  participant exon_phases

  get_transcript_attr_records->>get_limit_exons: limit_exons[transcript_id]
  activate get_limit_exons
  get_limit_exons-->>get_transcript_attr_records: lex
  deactivate get_limit_exons
  get_transcript_attr_records->>LimitExons: lex.set_phase_values(exon_phases[start_index, 0], exon_phases[stop_index, 1])
  activate LimitExons
  LimitExons-->>get_transcript_attr_records: 
  deactivate LimitExons
  get_transcript_attr_records->>LimitExons: lex.new_start
  activate LimitExons
  LimitExons-->>get_transcript_attr_records: new_start
  deactivate LimitExons
  get_transcript_attr_records->>LimitExons: lex.new_stop
  activate LimitExons
  LimitExons-->>get_transcript_attr_records: new_stop
  deactivate LimitExons
```

### File-Level Changes

| Change | Details | Files |
| ------ | ------- | ----- |
| Capture exon phase and end_phase in the exon_view table to adjust rel_start and rel_end values for accurate CDS spans. | <ul><li>Added `phase` and `end_phase` columns to the `LimitExons` dataclass.</li><li>Added `set_phase_values` method to the `LimitExons` dataclass to set phase values and issue a warning if `rel_start` is 0 and `phase` is greater than 0.</li><li>Added `new_start` and `new_stop` properties to the `LimitExons` dataclass to adjust start and stop positions based on phase values.</li><li>Modified `_adjust_single_exon` function to use `new_start` and `new_stop` properties of `LimitExons`.</li><li>Modified `get_transcript_attr_records` function to include `phase` and `end_phase` in the SQL query and processing.</li><li>Modified `make_transcript_attr` function to include `phase` and `end_phase` in the exon_view table creation.</li></ul> | `src/ensembl_tui/_mysql_core_attr.py` |
| Modified tests to include new columns in exon_view table. | <ul><li>Added `phase` and `end_phase` to `EXON_VIEW_SCHEMA`.</li><li>Added `phase` and `end_phase` to `EXON_SCHEMA`.</li><li>Updated test data in `four_exons`, `two_exons_minus_strand`, and `mixed_data` fixtures to include `phase` and `end_phase` columns.</li></ul> | `tests/test_install.py` |

---

<details>
<summary>Tips and commands</summary>

#### Interacting with Sourcery

- **Trigger a new review:** Comment `@sourcery-ai review` on the pull request.
- **Continue discussions:** Reply directly to Sourcery's review comments.
- **Generate a GitHub issue from a review comment:** Ask Sourcery to create an
  issue from a review comment by replying to it. You can also reply to a
  review comment with `@sourcery-ai issue` to create an issue from it.
- **Generate a pull request title:** Write `@sourcery-ai` anywhere in the pull
  request title to generate a title at any time. You can also comment
  `@sourcery-ai title` on the pull request to (re-)generate the title at any time.
- **Generate a pull request summary:** Write `@sourcery-ai summary` anywhere in
  the pull request body to generate a PR summary at any time exactly where you
  want it. You can also comment `@sourcery-ai summary` on the pull request to
  (re-)generate the summary at any time.
- **Generate reviewer's guide:** Comment `@sourcery-ai guide` on the pull
  request to (re-)generate the reviewer's guide at any time.
- **Resolve all Sourcery comments:** Comment `@sourcery-ai resolve` on the
  pull request to resolve all Sourcery comments. Useful if you've already
  addressed all the comments and don't want to see them anymore.
- **Dismiss all Sourcery reviews:** Comment `@sourcery-ai dismiss` on the pull
  request to dismiss all existing Sourcery reviews. Especially useful if you
  want to start fresh with a new review - don't forget to comment
  `@sourcery-ai review` to trigger a new review!
- **Generate a plan of action for an issue:** Comment `@sourcery-ai plan` on
  an issue to generate a plan of action for it.

#### Customizing Your Experience

Access your [dashboard](https://app.sourcery.ai) to:
- Enable or disable review features such as the Sourcery-generated pull request
  summary, the reviewer's guide, and others.
- Change the review language.
- Add, remove or edit custom review instructions.
- Adjust other review settings.

#### Getting Help

- [Contact our support team](mailto:support@sourcery.ai) for questions or feedback.
- Visit our [documentation](https://docs.sourcery.ai) for detailed guides and information.
- Keep in touch with the Sourcery team by following us on [X/Twitter](https://x.com/SourceryAI), [LinkedIn](https://www.linkedin.com/company/sourcery-ai/) or [GitHub](https://github.com/sourcery-ai).

</details>

<!-- Generated by sourcery-ai[bot]: end review_guide -->
author_association: NONE
user: login: coveralls
created_at: 2025-03-07T00:57:29Z
body: ## Pull Request Test Coverage Report for [Build 13711308057](https://coveralls.io/builds/72587974)


### Details

* **19** of **21**   **(90.48%)**  changed or added relevant lines in **1** file are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage increased (+**0.01%**) to **87.61%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [src/ensembl_tui/_mysql_core_attr.py](https://coveralls.io/builds/72587974/source?filename=src%2Fensembl_tui%2F_mysql_core_attr.py#L142) | 19 | 21 | 90.48%
<!-- | **Total:** | **19** | **21** | **90.48%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/72587974/badge)](https://coveralls.io/builds/72587974) |
| :-- | --: |
| Change from base [Build 13620639585](https://coveralls.io/builds/72501874): |  0.01% |
| Covered Lines: | 2680 |
| Relevant Lines: | 3059 |

---
##### 💛  - [Coveralls](https://coveralls.io)

author_association: COLLABORATOR