Repository: fulcrumgenomics/fgpyo (language: python)

Issue:
Feature request: better modeling of a pysam sequence dictionary
Rough sketch of functionality:

* class that takes the `pysam` list of dictionaries `reader.header["SQ"]` and stores something similar to fgbio's [`SequenceMetadata`](https://github.com/fulcrumgenomics/fgbio/blob/main/src/main/scala/com/fulcrumgenomics/fasta/SequenceDictionary.scala)
* method that takes a FASTA and returns the path to the sequence dictionary (simple as `Path(fasta).with_suffix(".dict")`?)
* method that takes a FASTA and returns the sequence dictionary class


Discussion / hints:
Working on this...

Fix patch:
diff --git a/fgpyo/fasta/sequence_dictionary.py b/fgpyo/fasta/sequence_dictionary.py
new file mode 100644
index 00000000..75ae5bec
--- /dev/null
+++ b/fgpyo/fasta/sequence_dictionary.py
@@ -0,0 +1,501 @@
+"""
+# Classes for representing sequencing dictionaries.
+
+## Examples of building and using sequence dictionaries
+
+Building a sequence dictionary from a `pysam.AlignmentHeader`:
+
+```python
+>>> import pysam
+>>> from fgpyo.fasta.sequence_dictionary import SequenceDictionary
+>>> sd: SequenceDictionary
+>>> with pysam.AlignmentFile("./fgpyo/sam/tests/data/valid.sam") as fh:
+    ...    sd = SequenceDictionary.from_sam(header=fh.header)
+...
+>>> print(sd)
+@SQ	SN:chr1	LN:101
+@SQ	SN:chr2	LN:101
+@SQ	SN:chr3	LN:101
+@SQ	SN:chr4	LN:101
+@SQ	SN:chr5	LN:101
+@SQ	SN:chr6	LN:101
+@SQ	SN:chr7	LN:404
+@SQ	SN:chr8	LN:202
+```
+
+Query based on index:
+
+```python
+>>> print(sd[3])
+@SQ	SN:chr4	LN:101
+```
+
+Query based on name:
+
+```python
+>>> print(sd["chr6"])
+@SQ	SN:chr6	LN:101
+```
+
+Add, get, and delete attributes:
+
+```python
+>>> meta = sd[0]
+>>> print(meta)
+@SQ	SN:chr1	LN:101
+>>> meta[Keys.ASSEMBLY] = "hg38"
+>>> print(meta))
+@SQ	SN:chr1	LN:101	AS:hg38
+>>> meta.get(Keys.ASSEMBLY)
+"hg38"
+>>> meta.get(Keys.SPECIES) is None
+True
+>>> Keys.MD5 in meta
+False
+>>> del meta[Keys.ASSEMBLY]
+>>> print(meta)
+@SQ	SN:chr1	LN:101
+```
+
+Get a sequence based on one of its aliases
+
+```python
+>>> meta[Keys.ALIASES] = "foo,bar,car"
+>>> sd = SequenceDictionary(infos=[meta] + sd.infos[1:])
+>>> print(sd)
+@SQ	SN:chr1	LN:101	AN:foo,bar,car
+@SQ	SN:chr2	LN:101
+@SQ	SN:chr3	LN:101
+@SQ	SN:chr4	LN:101
+@SQ	SN:chr5	LN:101
+@SQ	SN:chr6	LN:101
+@SQ	SN:chr7	LN:404
+@SQ	SN:chr8	LN:202
+>>> print(sd["chr1"])
+@SQ	SN:chr1	LN:101	AN:foo,bar,car
+>>> print(sd["bar"])
+@SQ	SN:chr1	LN:101	AN:foo,bar,car
+```
+
+Create a `pysam.AlignmentHeader` from a sequence dictionary:
+
+```python
+>>> sd.to_sam_header()
+<pysam.libcalignmentfile.AlignmentHeader object at 0x10e93f5f0>
+>>> print(sd.to_sam_header())
+@HD	VN:1.5
+@SQ	SN:chr1	LN:101	AN:foo,bar,car
+@SQ	SN:chr2	LN:101
+@SQ	SN:chr3	LN:101
+@SQ	SN:chr4	LN:101
+@SQ	SN:chr5	LN:101
+@SQ	SN:chr6	LN:101
+@SQ	SN:chr7	LN:404
+@SQ	SN:chr8	LN:202
+```
+
+Create a `pysam.AlignmentHeader` from a sequence dictionary with extra header items:
+
+```python
+>>> sd.to_sam_header(
+...     extra_header={"RG": [{"ID": "A", "LB": "a-library"}, {"ID": "B", "LB": "b-library"}]}
+... )
+<pysam.libcalignmentfile.AlignmentHeader object at 0x10e93fe30>
+>>> print(sd.to_sam_header(
+...     extra_header={"RG": [{"ID": "A", "LB": "a-library"}, {"ID": "B", "LB": "b-library"}]}
+... ))
+@HD	VN:1.5
+@SQ	SN:chr1	LN:101	AN:foo,bar,car
+@SQ	SN:chr2	LN:101
+@SQ	SN:chr3	LN:101
+@SQ	SN:chr4	LN:101
+@SQ	SN:chr5	LN:101
+@SQ	SN:chr6	LN:101
+@SQ	SN:chr7	LN:404
+@SQ	SN:chr8	LN:202
+@RG	ID:A	LB:a-library
+@RG	ID:B	LB:b-library
+```
+"""
+
+import copy
+import itertools
+import re
+import sys
+from dataclasses import dataclass
+from dataclasses import field
+from dataclasses import replace
+from enum import unique
+from typing import Any
+from typing import Dict
+from typing import Iterator
+from typing import List
+from typing import Mapping
+from typing import MutableMapping
+from typing import Optional
+from typing import Pattern
+from typing import Union
+from typing import overload
+
+if sys.version_info[0] == 3 and sys.version_info[0] < 11:
+    from strenum import StrEnum
+else:
+    from enum import StrEnum
+
+import pysam
+
+
+@unique
+class Topology(StrEnum):
+    """Enumeration for the topology of reference sequences (SAM @SQ.TP)"""
+
+    LINEAR = "LINEAR"
+    CIRCULAR = "CIRCULAR"
+
+
+@unique
+class Keys(StrEnum):
+    """Enumeration of tags/attributes available on a sequence record/metadata (SAM @SQ line)."""
+
+    ALIASES = "AN"
+    ALTERNATE_LOCUS = "AH"
+    ASSEMBLY = "AS"
+    DESCRIPTION = "DS"
+    SEQUENCE_LENGTH = "LN"
+    MD5 = "M5"
+    SEQUENCE_NAME = "SN"
+    SPECIES = "SP"
+    TOPOLOGY = "TP"
+    URI = "UR"
+
+    @staticmethod
+    def attributes() -> List[str]:
+        """The list of keys that are allowed to be attributes in `SequenceMetadata`.  Notably
+        `SEQUENCE_LENGTH` and `SEQUENCE_NAME` are not allowed."""
+        return [key for key in Keys if key != Keys.SEQUENCE_NAME and key != Keys.SEQUENCE_LENGTH]
+
+
+@dataclass(frozen=True, init=True)
+class AlternateLocus:
+    """Stores an alternate locus for an associated sequence (1-based inclusive)"""
+
+    name: str
+    start: int
+    end: int
+
+    def __post_init__(self) -> None:
+        """Any post initialization validation should go here"""
+        if self.start > self.end:
+            raise ValueError(f"start > end: {self.start} > {self.end}")
+        if self.start < 1:
+            raise ValueError(f"start < 1: {self.start}")
+
+    def __str__(self) -> str:
+        return f"{self.name}:{self.start}-{self.end}"
+
+    def __len__(self) -> int:
+        return self.end - self.start + 1
+
+    @staticmethod
+    def parse(value: str) -> "AlternateLocus":
+        """Parse the genomic interval of format: `<contig>:<start>-<end>`"""
+        name, rest = value.split(":", maxsplit=1)
+        start, end = rest.split("-", maxsplit=1)
+        return AlternateLocus(name=name, start=int(start), end=int(end))
+
+
+SEQUENCE_NAME_PATTERN: Pattern = re.compile(
+    "^[0-9A-Za-z!#$%&+./:;?@^_|~-][0-9A-Za-z!#$%&*+./:;=?@^_|~-]*$"
+)
+"""Regular expression for valid reference sequence names according to the SAM spec"""
+
+
+@dataclass(frozen=True, init=True)
+class SequenceMetadata(MutableMapping[Union[Keys, str], str]):
+    """Stores information about a single Sequence (ex. chromosome, contig).
+
+    Implements the mutable mapping interface, which provide access to the attributes of this
+    sequence, including name, length, but not index.  When using the mapping interface, for example
+    getting, setting, deleting, as well as iterating over keys, values, and items, the _values_ will
+    always be strings (`str` type).  For example, the length will be an `str` when accessing via
+    `get`; access the length directly or use `len` to return an `int`.  Similarly, use the
+    `alias` property to return a `List[str]` of aliases, use the `alternate` property to return
+    an `AlternativeLocus`-typed instance, and `topology` property to return a `Toplogy`-typed
+    instance.
+
+    All attributes except name and length may be set.  Use `dataclasses.replace` to create a new
+    copy in such cases.
+
+    Important: The `len` method returns the length of the sequence, not the length of the
+    attributes.  Use `len(meta.attributes)` for the latter.
+
+    Attributes:
+      name: the primary name of the sequence
+      length: the length of the sequence, or zero if unknown
+      index: the index in the sequence dictionary
+      attributes: attributes of this sequence
+    """
+
+    name: str
+    length: int
+    index: int
+    attributes: Dict[Union[Keys, str], str] = field(default_factory=dict)
+
+    def __post_init__(self) -> None:
+        """Any post initialization validation should go here"""
+        if self.length < 0:
+            raise ValueError(f"Length must be >= 0 for '{self.name}'")
+        if re.search(SEQUENCE_NAME_PATTERN, self.name) is None:
+            raise ValueError(f"Illegal name: '{self.name}'")
+        if Keys.SEQUENCE_NAME in self.attributes:
+            raise ValueError(f"'{Keys.SEQUENCE_NAME}' should not given in the list of attributes")
+        if Keys.SEQUENCE_LENGTH in self.attributes:
+            raise ValueError(f"'{Keys.SEQUENCE_LENGTH}' should not given in the list of attributes")
+
+    @property
+    def aliases(self) -> List[str]:
+        """The aliases (not including the primary) name"""
+        aliases = self.attributes.get(Keys.ALIASES)
+        return [] if aliases is None else aliases.split(",")
+
+    @property
+    def all_names(self) -> List[str]:
+        """A list of all names, including the primary name and aliases, in that order."""
+        return [self.name] + self.aliases
+
+    @property
+    def alternate(self) -> Optional[AlternateLocus]:
+        """Gets the alternate locus for this sequence"""
+        if Keys.ALTERNATE_LOCUS not in self.attributes:
+            return None
+        value = self.attributes[Keys.ALTERNATE_LOCUS]
+        if value == "*":
+            return None
+        locus = AlternateLocus.parse(value)
+        if locus.name == "=":
+            locus = replace(locus, name=self.name)
+        return locus
+
+    @property
+    def is_alternate(self) -> bool:
+        """True if there is an alternate locus defined, False otherwise"""
+        return self.alternate is not None
+
+    @property
+    def md5(self) -> Optional[str]:
+        return self.get(Keys.MD5)
+
+    @property
+    def assembly(self) -> Optional[str]:
+        return self.get(Keys.ASSEMBLY)
+
+    @property
+    def uri(self) -> Optional[str]:
+        return self.get(Keys.URI)
+
+    @property
+    def species(self) -> Optional[str]:
+        return self.get(Keys.SPECIES)
+
+    @property
+    def description(self) -> Optional[str]:
+        return self.get(Keys.DESCRIPTION)
+
+    @property
+    def topology(self) -> Optional[Topology]:
+        value = self.get(Keys.TOPOLOGY)
+        return None if value is None else Topology[value]
+
+    def same_as(self, other: "SequenceMetadata") -> bool:
+        """Returns true if the sequences share a common reference name (including aliases), have
+        the same length, and the same MD5 if both have MD5s."""
+        if self.length != other.length:
+            return False
+        elif self.name != other.name and other.name not in self.all_names:
+            return False
+        self_m5 = self.md5
+        other_m5 = other.md5
+        if self_m5 is None or other_m5 is None:
+            return True
+        else:
+            return self_m5 == other_m5
+
+    def to_sam(self) -> Dict[str, Any]:
+        """Converts the sequence metadata to a dictionary equivalent to one item in the
+        list of sequences from `pysam.AlignmentHeader#to_dict()["SQ"]`."""
+        meta_dict: Dict[str, Any] = {
+            f"{Keys.SEQUENCE_NAME}": self.name,
+            f"{Keys.SEQUENCE_LENGTH}": self.length,
+        }
+        if len(self.attributes) > 0:
+            meta_dict = {**meta_dict, **self.attributes}
+
+        return meta_dict
+
+    @staticmethod
+    def from_sam(meta: Dict[Union[Keys, str], Any], index: int) -> "SequenceMetadata":
+        """Builds a `SequenceMetadata` from a dictionary.  The keys must include the sequence
+        name (`Keys.SEQUENCE_NAME`) and length (`Keys.SEQUENCE_LENGTH`).  All other keys from
+        `Keys` will be stored in the resulting attributes.
+
+        Args:
+            meta: the python dictionary with keys from `Keys`.  This is typically the dictionary
+                  stored in the `"SQ"` level of the two-level dictionary returned by the
+                  `pysam.AlignmentHeader#to_dict()` method.
+            index: the 0-based index to use for this sequence
+        """
+        name = meta[Keys.SEQUENCE_NAME]
+        length = meta[Keys.SEQUENCE_LENGTH]
+        attributes = copy.deepcopy(meta)
+        del attributes[Keys.SEQUENCE_NAME]
+        del attributes[Keys.SEQUENCE_LENGTH]
+        return SequenceMetadata(name=name, length=length, index=index, attributes=attributes)
+
+    def __getitem__(self, key: Union[Keys, str]) -> Any:
+        if key == Keys.SEQUENCE_NAME.value:
+            return self.name
+        elif key == Keys.SEQUENCE_LENGTH.value:
+            return f"{self.length}"
+        return self.attributes[key]
+
+    def __setitem__(self, key: Union[Keys, str], value: str) -> None:
+        if key == Keys.SEQUENCE_NAME or key == Keys.SEQUENCE_LENGTH:
+            raise KeyError(f"Cannot set '{key}' on SequenceMetadata with name '{self.name}'")
+        self.attributes[key] = value
+
+    def __delitem__(self, key: Union[Keys, str]) -> None:
+        if key == Keys.SEQUENCE_NAME or key == Keys.SEQUENCE_LENGTH:
+            raise KeyError(f"Cannot delete '{key}' on SequenceMetadata with name '{self.name}'")
+        del self.attributes[key]
+
+    def __iter__(self) -> Iterator[Union[Keys, str]]:
+        pre_iter = iter((Keys.SEQUENCE_NAME, Keys.SEQUENCE_LENGTH))
+        return itertools.chain(pre_iter, iter(self.attributes))
+
+    def __len__(self) -> int:
+        return self.length
+
+    def __str__(self) -> str:
+        return "@SQ\t" + "\t".join(f"{key}:{value}" for key, value in self.to_sam().items())
+
+    def __index__(self) -> int:
+        return self.index
+
+
+@dataclass(frozen=True, init=True)
+class SequenceDictionary(Mapping[Union[str, int], SequenceMetadata]):
+    """Contains an ordered collection of sequences.
+
+    A specific `SequenceMetadata` may be retrieved by name (`str`) or index (`int`), either by
+    using the generic `get` method or by the correspondingly named `by_name` and `by_index` methods.
+    The latter methods provide faster retrieval when the type is known.
+
+    This _mapping_ collection iterates over the _keys_.  To iterate over each `SequenceMetadata`,
+    either use the typical `values()` method or access the metadata directly with `infos`.
+
+    Attributes:
+        infos: the ordered collection of sequence metadata
+    """
+
+    infos: List[SequenceMetadata]
+    _dict: Dict[str, SequenceMetadata] = field(init=False, repr=False)
+
+    def __post_init__(self) -> None:
+        # Initialize a mapping from sequence name to the sequence metadata for all names
+        self_dict: Dict[str, SequenceMetadata] = {}
+        for index, info in enumerate(self.infos):
+            if info.index != index:
+                raise ValueError(
+                    "Infos must be given with index set correctly."
+                    + f"  See ${index}th with name: {info.name}"
+                )
+            for name in info.all_names:
+                if name in self_dict:
+                    raise ValueError(f"Found duplicate sequence name: {name}")
+                self_dict[name] = info
+        object.__setattr__(self, "_dict", self_dict)
+
+    def same_as(self, other: "SequenceDictionary") -> bool:
+        """Returns true if the sequences share a common reference name (including aliases), have
+        the same length, and the same MD5 if both have MD5s"""
+        if len(self) != len(other):
+            return False
+        return all(this.same_as(that) for this, that in zip(self.infos, other.infos))
+
+    def to_sam(self) -> List[Dict[str, Any]]:
+        """Converts the list of dictionaries, one per sequence."""
+        return [meta.to_sam() for meta in self.infos]
+
+    def to_sam_header(
+        self,
+        extra_header: Optional[Dict[str, Any]] = None,
+    ) -> pysam.AlignmentHeader:
+        """Converts the sequence dictionary to a `pysam.AlignmentHeader`.
+
+        Args:
+            extra_header: a dictionary of extra values to add to the header, None otherwise.  See
+                          `:~pysam.AlignmentHeader` for more details.
+        """
+        header_dict: Dict[str, Any] = {
+            "HD": {"VN": "1.5"},
+            "SQ": self.to_sam(),
+        }
+        if extra_header is not None:
+            header_dict = {**header_dict, **extra_header}
+        return pysam.AlignmentHeader.from_dict(header_dict=header_dict)
+
+    @staticmethod
+    @overload
+    def from_sam(header: pysam.AlignmentHeader) -> "SequenceDictionary": ...
+
+    @staticmethod
+    @overload
+    def from_sam(header: List[Dict[str, Any]]) -> "SequenceDictionary": ...
+
+    @staticmethod
+    def from_sam(
+        header: Union[pysam.AlignmentHeader, List[Dict[str, Any]]],
+    ) -> "SequenceDictionary":
+        """Creates a `SequenceDictionary` from either a `pysam.AlignmentHeader` or from
+        the list of sequences returned by `pysam.AlignmentHeader#to_dict()["SQ"]`."""
+        if isinstance(header, pysam.AlignmentHeader):
+            return SequenceDictionary.from_sam(header=header.to_dict()["SQ"])
+
+        infos: List[SequenceMetadata] = [
+            SequenceMetadata.from_sam(meta=meta, index=index) for index, meta in enumerate(header)
+        ]
+
+        return SequenceDictionary(infos=infos)
+
+    # TODO: mypyp doesn't like these
+    # @overload
+    # def __getitem__(self, key: str) -> SequenceMetadata: ...
+    #
+    # @overload
+    # def __getitem__(self, index: int) -> SequenceMetadata: ...
+
+    def __getitem__(self, key: Union[str, int]) -> SequenceMetadata:
+        return self._dict[key] if isinstance(key, str) else self.infos[key]
+
+    def get_by_name(self, name: str) -> Optional[SequenceMetadata]:
+        """Gets a `SequenceMetadata` explicitly by `name`.  Returns None if
+        the name does not exist in this dictionary"""
+        return self._dict.get(name)
+
+    def by_name(self, name: str) -> SequenceMetadata:
+        """Gets a `SequenceMetadata` explicitly by `name`.  The name must exist."""
+        return self._dict[name]
+
+    def by_index(self, index: int) -> SequenceMetadata:
+        """Gets a `SequenceMetadata` explicitly by `name`.  Raises an `IndexError`
+        if the index is out of bounds."""
+        return self.infos[index]
+
+    def __iter__(self) -> Iterator[str]:
+        return iter(self._dict)
+
+    def __len__(self) -> int:
+        return len(self.infos)
+
+    def __str__(self) -> str:
+        return "\n".join(f"{info}" for info in self.infos)
diff --git a/poetry.lock b/poetry.lock
index 607f492d..b76f0794 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -110,13 +110,13 @@ files = [
 
 [[package]]
 name = "certifi"
-version = "2024.6.2"
+version = "2024.7.4"
 description = "Python package for providing Mozilla's CA Bundle."
 optional = true
 python-versions = ">=3.6"
 files = [
-    {file = "certifi-2024.6.2-py3-none-any.whl", hash = "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56"},
-    {file = "certifi-2024.6.2.tar.gz", hash = "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516"},
+    {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"},
+    {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"},
 ]
 
 [[package]]
@@ -245,63 +245,63 @@ files = [
 
 [[package]]
 name = "coverage"
-version = "7.5.4"
+version = "7.6.0"
 description = "Code coverage measurement for Python"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "coverage-7.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6cfb5a4f556bb51aba274588200a46e4dd6b505fb1a5f8c5ae408222eb416f99"},
-    {file = "coverage-7.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2174e7c23e0a454ffe12267a10732c273243b4f2d50d07544a91198f05c48f47"},
-    {file = "coverage-7.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2214ee920787d85db1b6a0bd9da5f8503ccc8fcd5814d90796c2f2493a2f4d2e"},
-    {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1137f46adb28e3813dec8c01fefadcb8c614f33576f672962e323b5128d9a68d"},
-    {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b385d49609f8e9efc885790a5a0e89f2e3ae042cdf12958b6034cc442de428d3"},
-    {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4a474f799456e0eb46d78ab07303286a84a3140e9700b9e154cfebc8f527016"},
-    {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5cd64adedf3be66f8ccee418473c2916492d53cbafbfcff851cbec5a8454b136"},
-    {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e564c2cf45d2f44a9da56f4e3a26b2236504a496eb4cb0ca7221cd4cc7a9aca9"},
-    {file = "coverage-7.5.4-cp310-cp310-win32.whl", hash = "sha256:7076b4b3a5f6d2b5d7f1185fde25b1e54eb66e647a1dfef0e2c2bfaf9b4c88c8"},
-    {file = "coverage-7.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:018a12985185038a5b2bcafab04ab833a9a0f2c59995b3cec07e10074c78635f"},
-    {file = "coverage-7.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:db14f552ac38f10758ad14dd7b983dbab424e731588d300c7db25b6f89e335b5"},
-    {file = "coverage-7.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3257fdd8e574805f27bb5342b77bc65578e98cbc004a92232106344053f319ba"},
-    {file = "coverage-7.5.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a6612c99081d8d6134005b1354191e103ec9705d7ba2754e848211ac8cacc6b"},
-    {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d45d3cbd94159c468b9b8c5a556e3f6b81a8d1af2a92b77320e887c3e7a5d080"},
-    {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed550e7442f278af76d9d65af48069f1fb84c9f745ae249c1a183c1e9d1b025c"},
-    {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a892be37ca35eb5019ec85402c3371b0f7cda5ab5056023a7f13da0961e60da"},
-    {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8192794d120167e2a64721d88dbd688584675e86e15d0569599257566dec9bf0"},
-    {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:820bc841faa502e727a48311948e0461132a9c8baa42f6b2b84a29ced24cc078"},
-    {file = "coverage-7.5.4-cp311-cp311-win32.whl", hash = "sha256:6aae5cce399a0f065da65c7bb1e8abd5c7a3043da9dceb429ebe1b289bc07806"},
-    {file = "coverage-7.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:d2e344d6adc8ef81c5a233d3a57b3c7d5181f40e79e05e1c143da143ccb6377d"},
-    {file = "coverage-7.5.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:54317c2b806354cbb2dc7ac27e2b93f97096912cc16b18289c5d4e44fc663233"},
-    {file = "coverage-7.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:042183de01f8b6d531e10c197f7f0315a61e8d805ab29c5f7b51a01d62782747"},
-    {file = "coverage-7.5.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bb74ed465d5fb204b2ec41d79bcd28afccf817de721e8a807d5141c3426638"},
-    {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3d45ff86efb129c599a3b287ae2e44c1e281ae0f9a9bad0edc202179bcc3a2e"},
-    {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5013ed890dc917cef2c9f765c4c6a8ae9df983cd60dbb635df8ed9f4ebc9f555"},
-    {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1014fbf665fef86cdfd6cb5b7371496ce35e4d2a00cda501cf9f5b9e6fced69f"},
-    {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3684bc2ff328f935981847082ba4fdc950d58906a40eafa93510d1b54c08a66c"},
-    {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:581ea96f92bf71a5ec0974001f900db495488434a6928a2ca7f01eee20c23805"},
-    {file = "coverage-7.5.4-cp312-cp312-win32.whl", hash = "sha256:73ca8fbc5bc622e54627314c1a6f1dfdd8db69788f3443e752c215f29fa87a0b"},
-    {file = "coverage-7.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:cef4649ec906ea7ea5e9e796e68b987f83fa9a718514fe147f538cfeda76d7a7"},
-    {file = "coverage-7.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdd31315fc20868c194130de9ee6bfd99755cc9565edff98ecc12585b90be882"},
-    {file = "coverage-7.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:02ff6e898197cc1e9fa375581382b72498eb2e6d5fc0b53f03e496cfee3fac6d"},
-    {file = "coverage-7.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d05c16cf4b4c2fc880cb12ba4c9b526e9e5d5bb1d81313d4d732a5b9fe2b9d53"},
-    {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5986ee7ea0795a4095ac4d113cbb3448601efca7f158ec7f7087a6c705304e4"},
-    {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df54843b88901fdc2f598ac06737f03d71168fd1175728054c8f5a2739ac3e4"},
-    {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ab73b35e8d109bffbda9a3e91c64e29fe26e03e49addf5b43d85fc426dde11f9"},
-    {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:aea072a941b033813f5e4814541fc265a5c12ed9720daef11ca516aeacd3bd7f"},
-    {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:16852febd96acd953b0d55fc842ce2dac1710f26729b31c80b940b9afcd9896f"},
-    {file = "coverage-7.5.4-cp38-cp38-win32.whl", hash = "sha256:8f894208794b164e6bd4bba61fc98bf6b06be4d390cf2daacfa6eca0a6d2bb4f"},
-    {file = "coverage-7.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:e2afe743289273209c992075a5a4913e8d007d569a406ffed0bd080ea02b0633"},
-    {file = "coverage-7.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b95c3a8cb0463ba9f77383d0fa8c9194cf91f64445a63fc26fb2327e1e1eb088"},
-    {file = "coverage-7.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d7564cc09dd91b5a6001754a5b3c6ecc4aba6323baf33a12bd751036c998be4"},
-    {file = "coverage-7.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44da56a2589b684813f86d07597fdf8a9c6ce77f58976727329272f5a01f99f7"},
-    {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e16f3d6b491c48c5ae726308e6ab1e18ee830b4cdd6913f2d7f77354b33f91c8"},
-    {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbc5958cb471e5a5af41b0ddaea96a37e74ed289535e8deca404811f6cb0bc3d"},
-    {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a04e990a2a41740b02d6182b498ee9796cf60eefe40cf859b016650147908029"},
-    {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ddbd2f9713a79e8e7242d7c51f1929611e991d855f414ca9996c20e44a895f7c"},
-    {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b1ccf5e728ccf83acd313c89f07c22d70d6c375a9c6f339233dcf792094bcbf7"},
-    {file = "coverage-7.5.4-cp39-cp39-win32.whl", hash = "sha256:56b4eafa21c6c175b3ede004ca12c653a88b6f922494b023aeb1e836df953ace"},
-    {file = "coverage-7.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:65e528e2e921ba8fd67d9055e6b9f9e34b21ebd6768ae1c1723f4ea6ace1234d"},
-    {file = "coverage-7.5.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:79b356f3dd5b26f3ad23b35c75dbdaf1f9e2450b6bcefc6d0825ea0aa3f86ca5"},
-    {file = "coverage-7.5.4.tar.gz", hash = "sha256:a44963520b069e12789d0faea4e9fdb1e410cdc4aab89d94f7f55cbb7fef0353"},
+    {file = "coverage-7.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dff044f661f59dace805eedb4a7404c573b6ff0cdba4a524141bc63d7be5c7fd"},
+    {file = "coverage-7.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8659fd33ee9e6ca03950cfdcdf271d645cf681609153f218826dd9805ab585c"},
+    {file = "coverage-7.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7792f0ab20df8071d669d929c75c97fecfa6bcab82c10ee4adb91c7a54055463"},
+    {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4b3cd1ca7cd73d229487fa5caca9e4bc1f0bca96526b922d61053ea751fe791"},
+    {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7e128f85c0b419907d1f38e616c4f1e9f1d1b37a7949f44df9a73d5da5cd53c"},
+    {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a94925102c89247530ae1dab7dc02c690942566f22e189cbd53579b0693c0783"},
+    {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dcd070b5b585b50e6617e8972f3fbbee786afca71b1936ac06257f7e178f00f6"},
+    {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d50a252b23b9b4dfeefc1f663c568a221092cbaded20a05a11665d0dbec9b8fb"},
+    {file = "coverage-7.6.0-cp310-cp310-win32.whl", hash = "sha256:0e7b27d04131c46e6894f23a4ae186a6a2207209a05df5b6ad4caee6d54a222c"},
+    {file = "coverage-7.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:54dece71673b3187c86226c3ca793c5f891f9fc3d8aa183f2e3653da18566169"},
+    {file = "coverage-7.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7b525ab52ce18c57ae232ba6f7010297a87ced82a2383b1afd238849c1ff933"},
+    {file = "coverage-7.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bea27c4269234e06f621f3fac3925f56ff34bc14521484b8f66a580aacc2e7d"},
+    {file = "coverage-7.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed8d1d1821ba5fc88d4a4f45387b65de52382fa3ef1f0115a4f7a20cdfab0e94"},
+    {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01c322ef2bbe15057bc4bf132b525b7e3f7206f071799eb8aa6ad1940bcf5fb1"},
+    {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03cafe82c1b32b770a29fd6de923625ccac3185a54a5e66606da26d105f37dac"},
+    {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d1b923fc4a40c5832be4f35a5dab0e5ff89cddf83bb4174499e02ea089daf57"},
+    {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4b03741e70fb811d1a9a1d75355cf391f274ed85847f4b78e35459899f57af4d"},
+    {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a73d18625f6a8a1cbb11eadc1d03929f9510f4131879288e3f7922097a429f63"},
+    {file = "coverage-7.6.0-cp311-cp311-win32.whl", hash = "sha256:65fa405b837060db569a61ec368b74688f429b32fa47a8929a7a2f9b47183713"},
+    {file = "coverage-7.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6379688fb4cfa921ae349c76eb1a9ab26b65f32b03d46bb0eed841fd4cb6afb1"},
+    {file = "coverage-7.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f7db0b6ae1f96ae41afe626095149ecd1b212b424626175a6633c2999eaad45b"},
+    {file = "coverage-7.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bbdf9a72403110a3bdae77948b8011f644571311c2fb35ee15f0f10a8fc082e8"},
+    {file = "coverage-7.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc44bf0315268e253bf563f3560e6c004efe38f76db03a1558274a6e04bf5d5"},
+    {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da8549d17489cd52f85a9829d0e1d91059359b3c54a26f28bec2c5d369524807"},
+    {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0086cd4fc71b7d485ac93ca4239c8f75732c2ae3ba83f6be1c9be59d9e2c6382"},
+    {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fad32ee9b27350687035cb5fdf9145bc9cf0a094a9577d43e909948ebcfa27b"},
+    {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:044a0985a4f25b335882b0966625270a8d9db3d3409ddc49a4eb00b0ef5e8cee"},
+    {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76d5f82213aa78098b9b964ea89de4617e70e0d43e97900c2778a50856dac605"},
+    {file = "coverage-7.6.0-cp312-cp312-win32.whl", hash = "sha256:3c59105f8d58ce500f348c5b56163a4113a440dad6daa2294b5052a10db866da"},
+    {file = "coverage-7.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:ca5d79cfdae420a1d52bf177de4bc2289c321d6c961ae321503b2ca59c17ae67"},
+    {file = "coverage-7.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d39bd10f0ae453554798b125d2f39884290c480f56e8a02ba7a6ed552005243b"},
+    {file = "coverage-7.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beb08e8508e53a568811016e59f3234d29c2583f6b6e28572f0954a6b4f7e03d"},
+    {file = "coverage-7.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2e16f4cd2bc4d88ba30ca2d3bbf2f21f00f382cf4e1ce3b1ddc96c634bc48ca"},
+    {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6616d1c9bf1e3faea78711ee42a8b972367d82ceae233ec0ac61cc7fec09fa6b"},
+    {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4567d6c334c46046d1c4c20024de2a1c3abc626817ae21ae3da600f5779b44"},
+    {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d17c6a415d68cfe1091d3296ba5749d3d8696e42c37fca5d4860c5bf7b729f03"},
+    {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9146579352d7b5f6412735d0f203bbd8d00113a680b66565e205bc605ef81bc6"},
+    {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cdab02a0a941af190df8782aafc591ef3ad08824f97850b015c8c6a8b3877b0b"},
+    {file = "coverage-7.6.0-cp38-cp38-win32.whl", hash = "sha256:df423f351b162a702c053d5dddc0fc0ef9a9e27ea3f449781ace5f906b664428"},
+    {file = "coverage-7.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:f2501d60d7497fd55e391f423f965bbe9e650e9ffc3c627d5f0ac516026000b8"},
+    {file = "coverage-7.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7221f9ac9dad9492cecab6f676b3eaf9185141539d5c9689d13fd6b0d7de840c"},
+    {file = "coverage-7.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ddaaa91bfc4477d2871442bbf30a125e8fe6b05da8a0015507bfbf4718228ab2"},
+    {file = "coverage-7.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4cbe651f3904e28f3a55d6f371203049034b4ddbce65a54527a3f189ca3b390"},
+    {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:831b476d79408ab6ccfadaaf199906c833f02fdb32c9ab907b1d4aa0713cfa3b"},
+    {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46c3d091059ad0b9c59d1034de74a7f36dcfa7f6d3bde782c49deb42438f2450"},
+    {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4d5fae0a22dc86259dee66f2cc6c1d3e490c4a1214d7daa2a93d07491c5c04b6"},
+    {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:07ed352205574aad067482e53dd606926afebcb5590653121063fbf4e2175166"},
+    {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:49c76cdfa13015c4560702574bad67f0e15ca5a2872c6a125f6327ead2b731dd"},
+    {file = "coverage-7.6.0-cp39-cp39-win32.whl", hash = "sha256:482855914928c8175735a2a59c8dc5806cf7d8f032e4820d52e845d1f731dca2"},
+    {file = "coverage-7.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:543ef9179bc55edfd895154a51792b01c017c87af0ebaae092720152e19e42ca"},
+    {file = "coverage-7.6.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:6fe885135c8a479d3e37a7aae61cbd3a0fb2deccb4dda3c25f92a49189f766d6"},
+    {file = "coverage-7.6.0.tar.gz", hash = "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51"},
 ]
 
 [package.dependencies]
@@ -312,13 +312,13 @@ toml = ["tomli"]
 
 [[package]]
 name = "exceptiongroup"
-version = "1.2.1"
+version = "1.2.2"
 description = "Backport of PEP 654 (exception groups)"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"},
-    {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"},
+    {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
+    {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
 ]
 
 [package.extras]
@@ -624,13 +624,13 @@ mkdocs = ">=1.0.3"
 
 [[package]]
 name = "mkdocs-material"
-version = "9.5.27"
+version = "9.5.29"
 description = "Documentation that simply works"
 optional = true
 python-versions = ">=3.8"
 files = [
-    {file = "mkdocs_material-9.5.27-py3-none-any.whl", hash = "sha256:af8cc263fafa98bb79e9e15a8c966204abf15164987569bd1175fd66a7705182"},
-    {file = "mkdocs_material-9.5.27.tar.gz", hash = "sha256:a7d4a35f6d4a62b0c43a0cfe7e987da0980c13587b5bc3c26e690ad494427ec0"},
+    {file = "mkdocs_material-9.5.29-py3-none-any.whl", hash = "sha256:afc1f508e2662ded95f0a35a329e8a5acd73ee88ca07ba73836eb6fcdae5d8b4"},
+    {file = "mkdocs_material-9.5.29.tar.gz", hash = "sha256:3e977598ec15a4ddad5c4dfc9e08edab6023edb51e88f0729bd27be77e3d322a"},
 ]
 
 [package.dependencies]
@@ -1339,18 +1339,18 @@ files = [
 
 [[package]]
 name = "setuptools"
-version = "70.1.1"
+version = "70.3.0"
 description = "Easily download, build, install, upgrade, and uninstall Python packages"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "setuptools-70.1.1-py3-none-any.whl", hash = "sha256:a58a8fde0541dab0419750bcc521fbdf8585f6e5cb41909df3a472ef7b81ca95"},
-    {file = "setuptools-70.1.1.tar.gz", hash = "sha256:937a48c7cdb7a21eb53cd7f9b59e525503aa8abaf3584c730dc5f7a5bec3a650"},
+    {file = "setuptools-70.3.0-py3-none-any.whl", hash = "sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc"},
+    {file = "setuptools-70.3.0.tar.gz", hash = "sha256:f171bab1dfbc86b132997f26a119f6056a57950d058587841a0082e8830f9dc5"},
 ]
 
 [package.extras]
-docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
-testing = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.10.0)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
+doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
+test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.10.0)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
 
 [[package]]
 name = "six"
@@ -1363,6 +1363,22 @@ files = [
     {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
 ]
 
+[[package]]
+name = "strenum"
+version = "0.4.15"
+description = "An Enum that inherits from str."
+optional = false
+python-versions = "*"
+files = [
+    {file = "StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659"},
+    {file = "StrEnum-0.4.15.tar.gz", hash = "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff"},
+]
+
+[package.extras]
+docs = ["myst-parser[linkify]", "sphinx", "sphinx-rtd-theme"]
+release = ["twine"]
+test = ["pylint", "pytest", "pytest-black", "pytest-cov", "pytest-pylint"]
+
 [[package]]
 name = "tabulate"
 version = "0.9.0"
@@ -1520,4 +1536,4 @@ docs = ["black", "mkdocs", "mkdocs-autorefs", "mkdocs-gen-files", "mkdocs-includ
 [metadata]
 lock-version = "2.0"
 python-versions = ">=3.8.0,<4.0"
-content-hash = "48785f981b5d9283294d5f9ce637dc6edb84e410b85647b470594af3920c86f8"
+content-hash = "7a1c0aec94f08e1a0b2d686374620aef8bfa844a8994a9b7e0b8ef138c90f5f0"
diff --git a/pyproject.toml b/pyproject.toml
index 8bf0fe79..491d48e7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,7 +25,7 @@ include = ["LICENSE"]
 [tool.poetry.dependencies]
 python = ">=3.8.0,<4.0"
 attrs = ">=19.3.0"
-pysam = ">=0.22.0"
+pysam = ">=0.22.1"
 pytest = ">=7.4.0"
 typing_extensions = { version = ">=3.7.4", python = "<3.12" }
 mkdocs-autorefs = { version = ">=0.5.0", optional = true }
@@ -44,6 +44,7 @@ numpy = [
   {version = "^1.25.2", python = ">=3.9,<3.12"},
   {version = "^1.24.4", python = "<3.9"}
 ]
+strenum = "^0.4.15"
 
 [tool.poetry.extras]
 docs = [


Test patch:
diff --git a/tests/fgpyo/fasta/test_sequence_dictionary.py b/tests/fgpyo/fasta/test_sequence_dictionary.py
new file mode 100644
index 00000000..493f1d14
--- /dev/null
+++ b/tests/fgpyo/fasta/test_sequence_dictionary.py
@@ -0,0 +1,369 @@
+from typing import Any
+from typing import Dict
+from typing import List
+from typing import Union
+
+import pysam
+import pytest
+
+from fgpyo.fasta.sequence_dictionary import AlternateLocus
+from fgpyo.fasta.sequence_dictionary import Keys
+from fgpyo.fasta.sequence_dictionary import SequenceDictionary
+from fgpyo.fasta.sequence_dictionary import SequenceMetadata
+from fgpyo.fasta.sequence_dictionary import Topology
+
+
+def test_alternate_locus_raises_start_gt_end() -> None:
+    with pytest.raises(ValueError, match="start > end"):
+        AlternateLocus(name="chr1", start=10, end=9)
+
+
+def test_alternate_locus_raises_start_lt_one() -> None:
+    with pytest.raises(ValueError, match="start < 1"):
+        AlternateLocus(name="chr1", start=0, end=9)
+
+
+def test_alternate_locus_ok() -> None:
+    assert len(AlternateLocus(name="chr1", start=10, end=10)) == 1
+    assert len(AlternateLocus(name="chr1", start=1, end=10)) == 10
+
+
+@pytest.mark.parametrize(
+    "value, expected",
+    [
+        ("chr1:1-1", AlternateLocus(name="chr1", start=1, end=1)),
+        ("foo:100-200", AlternateLocus(name="foo", start=100, end=200)),
+    ],
+)
+def test_alternate_locus_parse(value: str, expected: AlternateLocus) -> None:
+    assert AlternateLocus.parse(value) == expected
+
+
+def test_sequence_metadata_raises_negative_length() -> None:
+    with pytest.raises(ValueError, match="Length must be >= 0"):
+        SequenceMetadata(name="chr1", length=-1, index=0)
+
+
+def test_sequence_metadata_raises_illegal_name() -> None:
+    with pytest.raises(ValueError, match="Illegal name"):
+        SequenceMetadata(name="chromosome one", length=1, index=100)
+
+
+def test_sequence_metadata_raises_sequence_name_in_attributes() -> None:
+    match_key = f"'{Keys.SEQUENCE_NAME}' should not given in the list of attributes"
+    with pytest.raises(ValueError, match=match_key):
+        SequenceMetadata(name="1", length=1, index=1, attributes={Keys.SEQUENCE_NAME: "SN"})
+
+
+def test_sequence_metadata_raises_sequence_length_in_attributes() -> None:
+    match_key = f"'{Keys.SEQUENCE_LENGTH}' should not given in the list of attributes"
+    with pytest.raises(ValueError, match=match_key):
+        SequenceMetadata(name="1", length=1, index=1, attributes={Keys.SEQUENCE_LENGTH: "12"})
+
+
+def test_sequence_metadata_aliases() -> None:
+    assert SequenceMetadata(name="1", length=1, index=0).aliases == []
+    assert SequenceMetadata(
+        name="1", length=1, index=0, attributes={Keys.ALIASES: "2,3,45"}
+    ).aliases == ["2", "3", "45"]
+
+
+def test_sequence_metadata_all_names() -> None:
+    assert SequenceMetadata(name="1", length=1, index=0).all_names == ["1"]
+    assert SequenceMetadata(
+        name="1", length=1, index=0, attributes={Keys.ALIASES: "2,3,45"}
+    ).all_names == ["1", "2", "3", "45"]
+
+
+def test_sequence_metadata_alternate() -> None:
+    meta = SequenceMetadata(name="1", length=1, index=0)
+    assert meta.alternate is None
+    assert not meta.is_alternate
+
+    meta = SequenceMetadata(name="1", length=1, index=0, attributes={Keys.ALTERNATE_LOCUS: "*"})
+    assert meta.alternate is None
+    assert not meta.is_alternate
+
+    meta = SequenceMetadata(name="1", length=1, index=0, attributes={Keys.ALTERNATE_LOCUS: "=:2-3"})
+    assert meta.alternate == AlternateLocus(name="1", start=2, end=3)
+    assert meta.is_alternate
+
+    meta = SequenceMetadata(name="1", length=1, index=0, attributes={Keys.ALTERNATE_LOCUS: "4:2-3"})
+    assert meta.alternate == AlternateLocus(name="4", start=2, end=3)
+    assert meta.is_alternate
+
+
+def test_sequence_metadata_keys() -> None:
+    meta = SequenceMetadata(name="1", length=1, index=0)
+    for key in Keys.attributes():
+        assert meta.get(key) is None, f"key: {key} meta: {meta}"
+    assert meta[Keys.SEQUENCE_NAME] == "1"
+    assert meta[Keys.SEQUENCE_LENGTH] == "1"
+    assert meta.length == 1
+    assert len(meta) == 1
+
+    valid_attributes = Keys.attributes()
+    attributes = {key: f"value-{key}" for key in valid_attributes}
+    attributes[Keys.TOPOLOGY] = Topology.LINEAR  # override as topology has a specific set of values
+    meta = SequenceMetadata(name="1", length=1, index=0, attributes=attributes)
+    for key in Keys:
+        if key in valid_attributes:
+            assert meta.get(key) is not None, f"key: {key} meta: {meta}"
+            assert meta[key] == attributes[key]
+    assert meta[Keys.SEQUENCE_NAME] == "1"
+    assert meta[Keys.SEQUENCE_LENGTH] == "1"
+    assert meta.length == 1
+    assert len(meta) == 1
+
+    assert meta.md5 == attributes[Keys.MD5]
+    assert meta.assembly == attributes[Keys.ASSEMBLY]
+    assert meta.uri == attributes[Keys.URI]
+    assert meta.species == attributes[Keys.SPECIES]
+    assert meta.description == attributes[Keys.DESCRIPTION]
+    assert meta.topology == Topology[attributes[Keys.TOPOLOGY]]
+
+
+@pytest.mark.parametrize(
+    "this, that, expected",
+    [
+        # all but the names are different
+        (
+            SequenceMetadata(name="chr1", length=10, index=0),
+            SequenceMetadata(name="chr2", length=10, index=0),
+            False,
+        ),
+        # all but the lengths are different
+        (
+            SequenceMetadata(name="chr1", length=10, index=0),
+            SequenceMetadata(name="chr1", length=9, index=0),
+            False,
+        ),
+        # all but the md5 are different
+        (
+            SequenceMetadata(name="chr1", length=10, index=0, attributes={Keys.MD5: "foo"}),
+            SequenceMetadata(name="chr1", length=10, index=0, attributes={Keys.MD5: "bar"}),
+            False,
+        ),
+        # no common name, even with aliases
+        (
+            SequenceMetadata(name="chr1", length=10, index=0, attributes={Keys.ALIASES: "foo,bar"}),
+            SequenceMetadata(name="chr2", length=10, index=0),
+            False,
+        ),
+        # exactly the same
+        (
+            SequenceMetadata(name="chr1", length=10, index=0),
+            SequenceMetadata(name="chr1", length=10, index=0),
+            True,
+        ),
+        # expected True even though index is different
+        (
+            SequenceMetadata(name="chr1", length=10, index=0),
+            SequenceMetadata(name="chr1", length=10, index=1),
+            True,
+        ),
+        # expected True even though (non-MD5) attributes are different
+        (
+            SequenceMetadata(name="chr1", length=10, index=0, attributes={Keys.ASSEMBLY: "foo"}),
+            SequenceMetadata(name="chr1", length=10, index=0, attributes={Keys.ASSEMBLY: "bar"}),
+            True,
+        ),
+        # expected True when there the same, but the name is in the alias of the other
+        (
+            SequenceMetadata(
+                name="chr1", length=10, index=0, attributes={Keys.ALIASES: "foo,chr2"}
+            ),
+            SequenceMetadata(
+                name="chr2", length=10, index=0, attributes={Keys.ALIASES: "foo,chr1"}
+            ),
+            True,
+        ),
+    ],
+)
+def test_sequence_metadata_same_as(
+    this: SequenceMetadata, that: SequenceMetadata, expected: bool
+) -> None:
+    assert this.same_as(other=that) == expected
+    assert that.same_as(other=this) == expected
+    assert that.same_as(other=that)
+    assert this.same_as(other=this)
+
+
+def test_sequence_metadata_to_and_from_sam() -> None:
+    valid_attributes = Keys.attributes()
+    attributes = {key: f"value-{key}" for key in valid_attributes}
+    attributes[Keys.TOPOLOGY] = Topology.LINEAR  # override as topology has a specific set of values
+    attributes[Keys.ALTERNATE_LOCUS] = "chr2:3-4"
+    meta = SequenceMetadata(name="1", length=1, index=0, attributes=attributes)
+
+    sam_dict: Dict[Union[Keys, str], Any] = {
+        Keys.SEQUENCE_NAME: meta.name,
+        Keys.SEQUENCE_LENGTH: meta.length,
+        Keys.ALIASES: ",".join(meta.aliases),
+        Keys.ALTERNATE_LOCUS: f"{meta.alternate}",
+        Keys.ASSEMBLY: f"{meta.assembly}",
+        Keys.DESCRIPTION: f"{meta.description}",
+        Keys.MD5: meta.md5,
+        Keys.SPECIES: meta.species,
+        Keys.TOPOLOGY: f"{meta.topology}",
+        Keys.URI: meta.uri,
+    }
+
+    assert meta.to_sam() == sam_dict
+    assert SequenceMetadata.from_sam(meta=sam_dict, index=0) == meta
+
+
+def test_sequence_dictionary_mutable_mapping() -> None:
+    meta = SequenceMetadata(
+        name="1",
+        length=123,
+        index=0,
+        attributes={Keys.MD5: "md5", Keys.ALIASES: "2", Keys.ASSEMBLY: "as"},
+    )
+    # __getitem__
+    assert meta[Keys.MD5] == "md5"
+    # __setitem__
+    meta[Keys.MD5] = "foo"
+    assert meta[Keys.MD5] == "foo"
+    for key in [Keys.SEQUENCE_NAME, Keys.SEQUENCE_LENGTH]:
+        with pytest.raises(KeyError, match="Cannot set"):
+            meta[key] = "foo"
+    # __delitem__
+    del meta[Keys.MD5]
+    assert Keys.MD5 not in meta
+    assert meta.get(Keys.MD5) is None
+    for key in [Keys.SEQUENCE_NAME, Keys.SEQUENCE_LENGTH]:
+        with pytest.raises(KeyError, match="Cannot delete"):
+            del meta[key]
+    # __iter__
+    assert list(meta) == [Keys.SEQUENCE_NAME, Keys.SEQUENCE_LENGTH, Keys.ALIASES, Keys.ASSEMBLY]
+    # __len__
+    assert len(meta) == 123
+    # other methods
+    assert list(meta.values()) == ["1", "123", "2", "as"]
+    assert list(meta.items()) == [
+        (Keys.SEQUENCE_NAME, "1"),
+        (Keys.SEQUENCE_LENGTH, "123"),
+        (Keys.ALIASES, "2"),
+        (Keys.ASSEMBLY, "as"),
+    ]
+
+
+@pytest.mark.parametrize(
+    "infos",
+    [
+        [
+            SequenceMetadata(name="chr1", length=0, index=0),
+            SequenceMetadata(name="chr1", length=0, index=2),
+        ],
+        [
+            SequenceMetadata(name="chr1", length=0, index=1),
+            SequenceMetadata(name="chr1", length=0, index=0),
+        ],
+        [
+            SequenceMetadata(name="chr1", length=0, index=0),
+            SequenceMetadata(name="chr1", length=0, index=0),
+        ],
+        [
+            SequenceMetadata(name="chr1", length=0, index=1),
+            SequenceMetadata(name="chr1", length=0, index=2),
+        ],
+    ],
+)
+def test_sequence_dictionary_index_out_of_order(infos: List[SequenceMetadata]) -> None:
+    with pytest.raises(ValueError, match="Infos must be given with index set correctly."):
+        SequenceDictionary(infos=infos)
+
+
+@pytest.mark.parametrize(
+    "infos",
+    [
+        [
+            SequenceMetadata(name="chr1", length=0, index=0),
+            SequenceMetadata(name="chr1", length=0, index=1),
+        ],
+        [
+            SequenceMetadata(name="chr1", length=0, index=0),
+            SequenceMetadata(name="chr2", length=0, index=1, attributes={Keys.ALIASES: "chr1"}),
+        ],
+        [
+            SequenceMetadata(name="chr1", length=0, index=0, attributes={Keys.ALIASES: "chr2"}),
+            SequenceMetadata(name="chr2", length=0, index=1),
+        ],
+    ],
+)
+def test_sequence_dictionary_duplicate_names(infos: List[SequenceMetadata]) -> None:
+    with pytest.raises(ValueError, match="Found duplicate sequence name"):
+        SequenceDictionary(infos=infos)
+
+
+def test_sequence_dictionary_same_as() -> None:
+    this = SequenceDictionary(
+        infos=[
+            SequenceMetadata(name="chr1", length=0, index=0),
+            SequenceMetadata(name="chr2", length=0, index=1),
+        ]
+    )
+    that = SequenceDictionary(
+        infos=[
+            SequenceMetadata(name="chr2", length=0, index=0),
+            SequenceMetadata(name="chr3", length=0, index=1),
+        ]
+    )
+    assert this.same_as(this)
+    assert that.same_as(that)
+    assert not this.same_as(that)
+
+
+# to_sam
+# from_sam
+
+
+def test_sequence_dictionary_to_and_from_sam() -> None:
+    sd = SequenceDictionary(
+        infos=[
+            SequenceMetadata(name="chr1", length=10, index=0),
+            SequenceMetadata(name="chr2", length=20, index=1, attributes={Keys.ALIASES: "chr3"}),
+        ]
+    )
+    mapping: List[Dict[str, Any]] = [
+        {Keys.SEQUENCE_NAME: "chr1", Keys.SEQUENCE_LENGTH: 10},
+        {Keys.SEQUENCE_NAME: "chr2", Keys.SEQUENCE_LENGTH: 20, Keys.ALIASES: "chr3"},
+    ]
+    header = pysam.AlignmentHeader.from_dict(
+        header_dict={"HD": {"VN": "1.5"}, "SQ": mapping, "RG": [{"ID": "foo"}]}
+    )
+
+    assert SequenceDictionary.from_sam(mapping) == sd
+    assert SequenceDictionary.from_sam(header) == sd
+    assert sd.to_sam_header(extra_header={"RG": [{"ID": "foo"}]})
+
+
+def test_sequence_dictionary_mapping() -> None:
+    sd = SequenceDictionary(
+        infos=[
+            SequenceMetadata(name="chr1", length=10, index=0),
+            SequenceMetadata(name="chr2", length=20, index=1, attributes={Keys.ALIASES: "chr3"}),
+        ]
+    )
+    # __getitem__
+    assert sd["chr1"] == sd.infos[0]
+    assert sd["chr2"] == sd.infos[1]
+    assert sd["chr3"] == sd.infos[1]  # alias
+    assert sd[0] == sd.infos[0]
+    assert sd[1] == sd.infos[1]
+    with pytest.raises(IndexError):
+        sd[2]
+    with pytest.raises(KeyError):
+        sd["chr4"]
+    # __iter__
+    assert list(iter(sd)) == ["chr1", "chr2", "chr3"]
+    # __len__
+    assert len(sd) == 2
+    # other methods
+    assert list(sd.values()) == sd.infos + [sd.infos[1]]
+    assert list(sd.items()) == [
+        ("chr1", sd.infos[0]),
+        ("chr2", sd.infos[1]),
+        ("chr3", sd.infos[1]),
+    ]