Repository: M0r13n/pyais

Issue:
Tracker output rate of turn shows up as enum name instead of float
When I write a turnrate to a file it shows up as "TurnRate.NO_TI_RIGHT" instead  of the expected float value. 
The code below generates lines like:
```
257002270,TurnRate.NO_TI_DEFAULT,0.0,5.316765,60.409667,199.1,511,,,,,,,,,,1729952133.733693,,,,5
```

This is due to the string representation of the `TurnRate` class. When I change `class TurnRate(float, Enum):` to `class TurnRate(float, ReprEnum):` it works as expected. This is also the approach that default enums such as [StrEnum](https://github.com/python/cpython/blob/c5b99f5c2c5347d66b9da362773969c531fb6c85/Lib/enum.py#L1352) (new in python3.11) take. 

I would be happy to submit a PR with the change of I change `class TurnRate(float, Enum):` to `class TurnRate(float, ReprEnum):` and similar constants, if it is considered a welcome change. 

```
#!/usr/bin/env python3
import base64
import csv
import dataclasses
import functools
import json
import time
import typing

import click
from tqdm.auto import tqdm

import pyais


def handle_track(track, writer):
    # called every time an AISTrack is created
    track_dict = dataclasses.asdict(track)
    writer.writerow(track_dict)


@click.command()
@click.argument("input", type=click.Path(exists=True))
@click.argument("output", type=click.File("w"))
@click.option("--ttl", type=float, default=30)
def convert(input, output, ttl):
    fieldnames = [field.name for field in dataclasses.fields(pyais.tracker.AISTrack)]

    writer = csv.DictWriter(output, fieldnames=fieldnames)
    writer.writeheader()
    with pyais.stream.FileReaderStream(input) as f:
        with pyais.AISTracker(ttl_in_seconds=ttl) as tracker:

            tracker.register_callback(
                pyais.tracker.AISTrackEvent.CREATED,
                functools.partial(handle_track, writer=writer),
            )
            tracker.register_callback(
                pyais.tracker.AISTrackEvent.UPDATED,
                functools.partial(handle_track, writer=writer),
            )
            tracker.register_callback(
                pyais.tracker.AISTrackEvent.DELETED,
                functools.partial(handle_track, writer=writer),
            )
            for i, msg in tqdm(enumerate(f)):
                tracker.update(msg)


if __name__ == "__main__":
    convert()

```

Hints from issue comments:
@M0r13n: Hey @SiggyF,

`ReprEnum` was only added in [Python 3.11](https://docs.python.org/3/library/enum.html#enum.ReprEnum). Python 3.10 is supported until 2026 ([source](https://devguide.python.org/versions/)). Therefore, this is currently not an option. However, I am open to alternative approaches, as I consider your issues to be valid concerns—provided that the solution is reasonably elegant. I am thinking about overriding `__str__`:

```py
def __str__(self):
    return str(float(self))
```

But I need to consider any undesired side effects@SiggyF: Thanks for considering. That's a good point. 

I see some constants in the pyais (ManeuverIndicator, EpfdType) use IntEnum. Their behaviour changes from Enum to ReprEnum by upgrading to python>=3.11  (as the updated IntEnum also uses ReprEnum since). So any issues of changing to the repr behaviour will also occur if users upgrade their python version to >= 3.11, for the integer types. 

Note that the changed behaviour (since 3.11) is implemented by [reusing/copying](https://github.com/python/cpython/blob/67b270142d58b8f9a64f8ae2b84a195e7e49adda/Lib/enum.py#L626) the `__str__` method of the implemented type in the [`__new__`](https://github.com/python/cpython/blob/67b270142d58b8f9a64f8ae2b84a195e7e49adda/Lib/enum.py#L611) method of EnumType's. [`ReprEnum`](https://github.com/python/cpython/blob/67b270142d58b8f9a64f8ae2b84a195e7e49adda/Lib/enum.py#L1292) itself does not do anything, it's just a subclass of Enum.  The code (comments in enum in cpython) of cpython mentions that in some cases the pickle method would break. So that could be something to test for. 
@M0r13n: @SiggyF I created a PR adding a simple helper class `ReprEnum` to unify the behavior of `str()` and `repr()` across all enums (see #155). What do you think? This should meet your use case and is compatible with any version of python.@SiggyF: Thanks, looks good. Much appreciated!

Solution patch:
diff --git a/pyais/constants.py b/pyais/constants.py
index 920af6a..8a3b4c1 100644
--- a/pyais/constants.py
+++ b/pyais/constants.py
@@ -1,5 +1,5 @@
 import typing
-from enum import Enum, IntEnum
+from enum import Enum
 
 # Keywords
 UNDEFINED = 'Undefined'
@@ -9,7 +9,13 @@
 ANSI_RESET = '\x1b[0m'
 
 
-class TurnRate(float, Enum):
+class ReprEnum(Enum):
+
+    def __str__(self) -> str:
+        return str(self.value)
+
+
+class TurnRate(float, ReprEnum):
     # Source: https://gpsd.gitlab.io/gpsd/AIVDM.html#_types_1_2_and_3_position_report_class_a
     # turning right at more than 5deg/30s (No TI available)
     NO_TI_RIGHT = 127
@@ -19,7 +25,7 @@ class TurnRate(float, Enum):
     NO_TI_DEFAULT = -128
 
 
-class TalkerID(str, Enum):
+class TalkerID(str, ReprEnum):
     """ Enum of all  NMEA talker IDs.
     See: https://gpsd.gitlab.io/gpsd/AIVDM.html#_talker_ids"""
     Base_Station = "AB"
@@ -43,7 +49,7 @@ def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["TalkerID
         return cls(v) if v is not None else None
 
 
-class NavigationStatus(IntEnum):
+class NavigationStatus(int, ReprEnum):
     UnderWayUsingEngine = 0
     AtAnchor = 1
     NotUnderCommand = 2
@@ -70,7 +76,7 @@ def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["Navigati
         return cls(v) if v is not None else None
 
 
-class ManeuverIndicator(IntEnum):
+class ManeuverIndicator(int, ReprEnum):
     NotAvailable = 0
     NoSpecialManeuver = 1
     SpecialManeuver = 2
@@ -85,7 +91,7 @@ def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["Maneuver
         return cls(v) if v is not None else None
 
 
-class EpfdType(IntEnum):
+class EpfdType(int, ReprEnum):
     Undefined = 0
     GPS = 1
     GLONASS = 2
@@ -106,7 +112,7 @@ def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["EpfdType
         return cls(v) if v is not None else None
 
 
-class ShipType(IntEnum):
+class ShipType(int, ReprEnum):
     NotAvailable = 0
     # 20's
     WIG = 20
@@ -207,7 +213,7 @@ def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["ShipType
         return cls(v) if v is not None else None
 
 
-class DacFid(IntEnum):
+class DacFid(int, ReprEnum):
     DangerousCargoIndication = 13
     TidalWindow = 15
     NumPersonsOnBoard = 17
@@ -226,7 +232,7 @@ def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["DacFid"]
         return cls(v) if v is not None else None
 
 
-class NavAid(IntEnum):
+class NavAid(int, ReprEnum):
     DEFAULT = 0
     REFERENCE_POINT = 1
     RACON = 2
@@ -269,7 +275,7 @@ def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["NavAid"]
         return cls(v) if v is not None else None
 
 
-class TransmitMode(IntEnum):
+class TransmitMode(int, ReprEnum):
     TXA_TXB_RXA_RXB = 0  # default
     TXA_RXA_RXB = 1
     TXB_RXA_RXB = 2
@@ -284,7 +290,7 @@ def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["Transmit
         return cls(v) if v is not None else None
 
 
-class StationType(IntEnum):
+class StationType(int, ReprEnum):
     ALL = 0
     RESERVED = 1
     CLASS_B_ALL = 2
@@ -307,7 +313,7 @@ def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["StationT
         return cls(v) if v is not None else None
 
 
-class StationIntervals(IntEnum):
+class StationIntervals(int, ReprEnum):
     AUTONOMOUS_MODE = 0
     MINUTES_10 = 1
     MINUTES_6 = 2
@@ -330,7 +336,7 @@ def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["StationI
         return cls(v) if v is not None else None
 
 
-class SyncState(IntEnum):
+class SyncState(int, ReprEnum):
     """
     https://www.navcen.uscg.gov/?pageName=AISMessagesA#Sync
     """


Test patch:
diff --git a/tests/test_repr.py b/tests/test_repr.py
new file mode 100644
index 0000000..cadbfc9
--- /dev/null
+++ b/tests/test_repr.py
@@ -0,0 +1,33 @@
+import unittest
+from pyais.constants import TurnRate, TalkerID, NavigationStatus, ManeuverIndicator, EpfdType, ShipType
+
+
+class TestPyAISConstants(unittest.TestCase):
+
+    def test_turn_rate_no_ti_default(self):
+        self.assertEqual(repr(TurnRate.NO_TI_DEFAULT), "<TurnRate.NO_TI_DEFAULT: -128.0>")
+        self.assertEqual(str(TurnRate.NO_TI_DEFAULT), "-128.0")
+
+    def test_talker_id_base_station(self):
+        self.assertEqual(repr(TalkerID.Base_Station), "<TalkerID.Base_Station: 'AB'>")
+        self.assertEqual(str(TalkerID.Base_Station), "AB")
+
+    def test_navigation_status_aground(self):
+        self.assertEqual(repr(NavigationStatus.Aground), "<NavigationStatus.Aground: 6>")
+        self.assertEqual(str(NavigationStatus.Aground), "6")
+
+    def test_maneuver_indicator_no_special_maneuver(self):
+        self.assertEqual(repr(ManeuverIndicator.NoSpecialManeuver), "<ManeuverIndicator.NoSpecialManeuver: 1>")
+        self.assertEqual(str(ManeuverIndicator.NoSpecialManeuver), "1")
+
+    def test_epfd_type_glonass(self):
+        self.assertEqual(repr(EpfdType.GLONASS), "<EpfdType.GLONASS: 2>")
+        self.assertEqual(str(EpfdType.GLONASS), "2")
+
+    def test_ship_type_wig_hazardous_category_a(self):
+        self.assertEqual(repr(ShipType.WIG_HazardousCategory_A), "<ShipType.WIG_HazardousCategory_A: 21>")
+        self.assertEqual(str(ShipType.WIG_HazardousCategory_A), "21")
+
+
+if __name__ == '__main__':
+    unittest.main()