From 0f92d281f1032dd03143d8952a1cde446804c637 Mon Sep 17 00:00:00 2001 From: Clint Valentine Date: Fri, 28 Jun 2024 18:20:14 -0700 Subject: [PATCH] feat: implement a generic overlap detector and other library fixups (#19) --- .gitignore | 1 + README.md | 40 +-- bedspec/__init__.py | 12 +- bedspec/_bedspec.py | 476 ++++++------------------------------ bedspec/_io.py | 199 +++++++++++++++ bedspec/_types.py | 115 +++++++++ bedspec/_typing.py | 62 +++++ bedspec/overlap/__init__.py | 3 + bedspec/overlap/_overlap.py | 149 +++++++++++ poetry.lock | 228 +++++++++-------- pyproject.toml | 7 +- tests/test_bedspec.py | 398 +++--------------------------- tests/test_io.py | 286 ++++++++++++++++++++++ tests/test_overlap.py | 115 +++++++++ tests/test_types.py | 73 ++++++ tests/test_typing.py | 8 + 16 files changed, 1290 insertions(+), 882 deletions(-) create mode 100644 bedspec/_io.py create mode 100644 bedspec/_types.py create mode 100644 bedspec/_typing.py create mode 100644 bedspec/overlap/__init__.py create mode 100644 bedspec/overlap/_overlap.py create mode 100644 tests/test_io.py create mode 100644 tests/test_overlap.py create mode 100644 tests/test_types.py create mode 100644 tests/test_typing.py diff --git a/.gitignore b/.gitignore index 12d296a..17de4aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +.vscode/ # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/README.md b/README.md index c103ec9..e790cb5 100644 --- a/README.md +++ b/README.md @@ -22,32 +22,27 @@ pip install bedspec ### Writing ```python -from bedspec import Bed3 -from bedspec import BedWriter - -bed = Bed3("chr1", start=2, end=8) +from bedspec import Bed3, BedWriter with BedWriter[Bed3].from_path("test.bed") as writer: - writer.write(bed) + writer.write(Bed3("chr1", start=2, end=8)) ``` ### Reading ```python -from bedspec import Bed3 -from bedspec import BedReader +from bedspec import Bed3, BedReader with BedReader[Bed3].from_path("test.bed") as reader: - for bed in reader: - print(bed) + print(list(reader)) ``` ```console -Bed3(contig="chr1", start=2, start=8) +[Bed3(contig="chr1", start=2, start=8)] ``` ### BED Types -This package provides pre-defined classes for the following BED formats: +This package provides builtin classes for the following BED formats: ```python from bedspec import Bed2 @@ -55,12 +50,29 @@ from bedspec import Bed3 from bedspec import Bed4 from bedspec import Bed5 from bedspec import Bed6 +from bedspec import BedGraph from bedspec import BedPE ``` -### Custom BED Types +### Overlap Detection + +Use a fast overlap detector for any collection of interval types, including third-party: + +```python +from bedspec import Bed3, Bed4 +from bedspec.overlap import OverlapDetector + +bed1: Bed4 = Bed4(contig="chr1", start=1, end=4, name="bed1") +bed2: Bed4 = Bed4(contig="chr1", start=5, end=9, name="bed2") + +detector: OverlapDetector[Bed4] = OverlapDetector([bed1, bed2]) + +assert detector.get_overlapping(Bed3(contig="chr1", start=2, 3)) == bed1 +``` + +### Create Custom BED Types -To create a custom BED record, inherit from the relevent BED-type: +To create a custom BED record, inherit from the relevant BED-type: | Type | Description | | --- | --- | @@ -75,7 +87,7 @@ from dataclasses import dataclass from bedspec import SimpleBed -@dataclass +@dataclass(eq=True, frozen=True) class MyCustomBed(SimpleBed): contig: str start: int diff --git a/bedspec/__init__.py b/bedspec/__init__.py index f6525f5..da870da 100644 --- a/bedspec/__init__.py +++ b/bedspec/__init__.py @@ -1,19 +1,19 @@ # ruff: noqa: F401 -from ._bedspec import COMMENT_PREFIXES -from ._bedspec import MISSING_FIELD from ._bedspec import Bed2 from ._bedspec import Bed3 from ._bedspec import Bed4 from ._bedspec import Bed5 from ._bedspec import Bed6 from ._bedspec import BedColor +from ._bedspec import BedGraph from ._bedspec import BedPE -from ._bedspec import BedReader from ._bedspec import BedStrand from ._bedspec import BedType -from ._bedspec import BedWriter -from ._bedspec import Locatable from ._bedspec import PairBed from ._bedspec import PointBed from ._bedspec import SimpleBed -from ._bedspec import Stranded +from ._io import BedReader +from ._io import BedWriter +from ._types import GenomicSpan +from ._types import Named +from ._types import Stranded diff --git a/bedspec/_bedspec.py b/bedspec/_bedspec.py index dda6013..bad9b0f 100644 --- a/bedspec/_bedspec.py +++ b/bedspec/_bedspec.py @@ -1,200 +1,12 @@ -import dataclasses -import inspect -import io from abc import ABC -from abc import abstractmethod -from dataclasses import asdict as as_dict from dataclasses import dataclass -from dataclasses import fields -from enum import StrEnum -from enum import unique -from functools import update_wrapper -from pathlib import Path -from types import FrameType -from types import TracebackType -from types import UnionType -from typing import Any -from typing import Callable -from typing import ClassVar -from typing import ContextManager -from typing import Generic -from typing import Iterable from typing import Iterator -from typing import Protocol -from typing import Type -from typing import TypeVar -from typing import Union -from typing import _BaseGenericAlias # type: ignore[attr-defined] -from typing import _GenericAlias # type: ignore[attr-defined] -from typing import cast -from typing import get_args -from typing import get_origin -from typing import get_type_hints -from typing import runtime_checkable -COMMENT_PREFIXES: set[str] = {"#", "browser", "track"} -"""The set of BED comment prefixes supported by this implementation.""" - -MISSING_FIELD: str = "." -"""The string used to indicate a missing field in a BED record.""" - -BED_EXTENSION: str = ".bed" -"""The specification defined file extension for BED files.""" - -BEDPE_EXTENSION: str = ".bedpe" -"""The specification defined file extension for BedPE files.""" - - -def is_union(annotation: Type) -> bool: - """Test if we have a union type annotation or not.""" - return get_origin(annotation) in {Union, UnionType} - - -def is_optional(annotation: Type) -> bool: - """Return if this type annotation is optional (a union type with None) or not.""" - return is_union(annotation) and type(None) in get_args(annotation) - - -def singular_non_optional_type(annotation: Type) -> Type: - """Return the non-optional version of a singular type annotation.""" - if not is_optional(annotation): - return annotation - - not_none: list[Type] = [arg for arg in get_args(annotation) if arg is not type(None)] - if len(not_none) == 1: - return not_none[0] - else: - raise TypeError(f"Complex non-optional types are not supported! Found: {not_none}") - - -class MethodType: - def __init__(self, func: Callable, obj: object) -> None: - self.__func__ = func - self.__self__ = obj - - def __call__(self, *args: object, **kwargs: object) -> object: - func = self.__func__ - obj = self.__self__ - return func(obj, *args, **kwargs) - - -class classmethod_generic: - def __init__(self, f: Callable) -> None: - self.f = f - update_wrapper(self, f) - - def __get__(self, obj: object, cls: object | None = None) -> Callable: - if cls is None: - cls = type(obj) - method = MethodType(self.f, cls) - method._generic_classmethod = True # type: ignore[attr-defined] - return method - - -def __getattr__(self: object, name: str | None = None) -> object: - if hasattr(obj := orig_getattr(self, name), "_generic_classmethod"): - obj.__self__ = self - return obj - - -orig_getattr = _BaseGenericAlias.__getattr__ -_BaseGenericAlias.__getattr__ = __getattr__ - - -@unique -class BedStrand(StrEnum): - """Valid BED strands for forward, reverse, and unknown directions.""" - - POSITIVE = "+" - NEGATIVE = "-" - - def opposite(self) -> "BedStrand": - """Return the opposite strand.""" - match self: - case BedStrand.POSITIVE: - return BedStrand.NEGATIVE - case BedStrand.NEGATIVE: - return BedStrand.POSITIVE - - -@dataclass -class BedColor: - """The color of a BED record in red, green, and blue values.""" - - r: int - g: int - b: int - - def __str__(self) -> str: - """Return a string representation of this BED color.""" - return f"{self.r},{self.g},{self.b}" - - -@runtime_checkable -class DataclassProtocol(Protocol): - """A protocol for objects that are dataclass instances.""" - - __dataclass_fields__: ClassVar[dict[str, Any]] - - -@runtime_checkable -class Locatable(Protocol): - """A protocol for 0-based half-open objects located on a reference sequence.""" - - contig: str - start: int - end: int - - -@runtime_checkable -class Stranded(Protocol): - """A protocol for stranded BED types.""" - - strand: BedStrand | None - - -class BedType(ABC, DataclassProtocol): - """An abstract base class for all types of BED records.""" - - def __new__(cls, *args: object, **kwargs: object) -> "BedType": - if not dataclasses.is_dataclass(cls): - raise TypeError("You must mark custom BED records with @dataclass!") - return cast("BedType", object.__new__(cls)) - - @classmethod - def decode(cls, line: str) -> "BedType": - """Decode a line of text into a BED record.""" - row: list[str] = line.strip().split() - coerced: dict[str, object] = {} - - try: - zipped = list(zip(fields(cls), row, strict=True)) - except ValueError: - raise ValueError( - f"Expected {len(fields(cls))} fields but found {len(row)} in record:" - f" '{' '.join(row)}'" - ) from None - - hints: dict[str, Type] = get_type_hints(cls) - - for field, value in zipped: - try: - if is_optional(hints[field.name]) and value == MISSING_FIELD: - coerced[field.name] = None - else: - coerced[field.name] = singular_non_optional_type(field.type)(value) - except ValueError: - raise TypeError( - f"Tried to build the BED field '{field.name}' (of type '{field.type.__name__}')" - f" from the value '{value}' but couldn't for record '{' '.join(row)}'" - ) from None - - return cls(**coerced) - - @abstractmethod - def territory(self) -> Iterator[Locatable]: - """Return intervals that describe the territory of this BED record.""" - pass +from bedspec._types import BedStrand +from bedspec._types import BedType +from bedspec._types import GenomicSpan +from bedspec._types import Named +from bedspec._types import Stranded class PointBed(BedType, ABC): @@ -208,35 +20,35 @@ def length(self) -> int: """The length of this record.""" return 1 - def territory(self) -> Iterator[Locatable]: + def territory(self) -> Iterator[GenomicSpan]: """Return the territory of a single point BED record which is 1-length.""" yield Bed3(contig=self.contig, start=self.start, end=self.start + 1) -class SimpleBed(BedType, ABC, Locatable): - """An abstract class for a BED record that describes a simple contiguous interval.""" +class SimpleBed(BedType, ABC, GenomicSpan): + """An abstract class for a BED record that describes a contiguous linear interval.""" contig: str start: int end: int def __post_init__(self) -> None: - """Validate this dataclass.""" + """Validate this linear BED record.""" if self.start >= self.end or self.start < 0: - raise ValueError("start must be greater than 0 and less than end!") + raise ValueError("Start must be greater than 0 and less than end!") @property def length(self) -> int: """The length of this record.""" return self.end - self.start - def territory(self) -> Iterator[Locatable]: - """Return the territory of a simple BED record which is just itself.""" + def territory(self) -> Iterator[GenomicSpan]: + """Return the territory of a linear BED record which is just itself.""" yield self class PairBed(BedType, ABC): - """An abstract base class for a BED record that describes a pair of intervals.""" + """An abstract base class for a BED record that describes a pair of linear linear intervals.""" contig1: str start1: int @@ -246,11 +58,11 @@ class PairBed(BedType, ABC): end2: int def __post_init__(self) -> None: - """Validate this dataclass.""" + """Validate this pair of BED records.""" if self.start1 >= self.end1 or self.start1 < 0: - raise ValueError("start1 must be greater than 0 and less than end1!") + raise ValueError("Start1 must be greater than 0 and less than end1!") if self.start2 >= self.end2 or self.start2 < 0: - raise ValueError("start2 must be greater than 0 and less than end2!") + raise ValueError("Start2 must be greater than 0 and less than end2!") @property def bed1(self) -> SimpleBed: @@ -262,13 +74,31 @@ def bed2(self) -> SimpleBed: """The second of the two intervals.""" return Bed3(contig=self.contig2, start=self.start2, end=self.end2) - def territory(self) -> Iterator[Locatable]: + def territory(self) -> Iterator[GenomicSpan]: """Return the territory of this BED record which are two intervals.""" yield self.bed1 yield self.bed2 -@dataclass +@dataclass(eq=True, frozen=True) +class BedColor: + """The color of a BED record in red, green, and blue color values.""" + + r: int + g: int + b: int + + def __str__(self) -> str: + """Return a comma-delimited string representation of this BED color.""" + return f"{self.r},{self.g},{self.b}" + + def __post_init__(self) -> None: + """Validate that all color values are well-formatted.""" + if any(value > 255 or value < 0 for value in (self.r, self.g, self.b)): + raise ValueError(f"RGB color values must be in the range [0, 255] but found: {self}") + + +@dataclass(eq=True, frozen=True) class Bed2(PointBed): """A BED2 record that describes a single 0-based 1-length point.""" @@ -276,18 +106,18 @@ class Bed2(PointBed): start: int -@dataclass +@dataclass(eq=True, frozen=True) class Bed3(SimpleBed): - """A BED3 record that describes a simple contiguous interval.""" + """A BED3 record that describes a contiguous linear interval.""" contig: str start: int end: int -@dataclass +@dataclass(eq=True, frozen=True) class Bed4(SimpleBed): - """A BED4 record that describes a simple contiguous interval.""" + """A BED4 record that describes a contiguous linear interval.""" contig: str start: int @@ -295,9 +125,9 @@ class Bed4(SimpleBed): name: str | None -@dataclass -class Bed5(SimpleBed): - """A BED5 record that describes a simple contiguous interval.""" +@dataclass(eq=True, frozen=True) +class Bed5(SimpleBed, Named): + """A BED5 record that describes a contiguous linear interval.""" contig: str start: int @@ -306,9 +136,9 @@ class Bed5(SimpleBed): score: int | None -@dataclass -class Bed6(SimpleBed, Stranded): - """A BED6 record that describes a simple contiguous interval.""" +@dataclass(eq=True, frozen=True) +class Bed6(SimpleBed, Stranded, Named): + """A BED6 record that describes a contiguous linear interval.""" contig: str start: int @@ -318,28 +148,36 @@ class Bed6(SimpleBed, Stranded): strand: BedStrand | None -# @dataclass -# class Bed12(SimpleBed, Stranded): -# """A BED12 record that describes a simple contiguous interval.""" -# contig: str -# start: int -# end: int -# name: str -# score: int -# strand: BedStrand -# thickStart: int -# thickEnd: int -# itemRgb: BedColor | None -# blockCount: int -# blockSizes: list[int] -# blockStarts: list[int] - -# TODO: Implement BED detail format? https://genome.ucsc.edu/FAQ/FAQformat.html#format1.7 -# TODO: Implement bedGraph format? https://genome.ucsc.edu/goldenPath/help/bedgraph.html - - -@dataclass -class BedPE(PairBed): +@dataclass(eq=True, frozen=True) +class Bed12(SimpleBed, Stranded, Named): + """A BED12 record that describes a contiguous linear interval.""" + + contig: str + start: int + end: int + name: str | None + score: int | None + strand: BedStrand | None + thickStart: int | None + thickEnd: int | None + itemRgb: BedColor | None + blockCount: int | None + blockSizes: list[int] + blockStarts: list[int] + + +@dataclass(eq=True, frozen=True) +class BedGraph(SimpleBed): + """A bedGraph feature for continuous-valued data.""" + + contig: str + start: int + end: int + value: float + + +@dataclass(eq=True, frozen=True) +class BedPE(PairBed, Named): """A BED record that describes a pair of BED records as per the bedtools spec.""" contig1: str @@ -376,165 +214,3 @@ def bed2(self) -> Bed6: score=self.score, strand=self.strand2, ) - - -BedKind = TypeVar("BedKind", bound=BedType) - - -class BedWriter(Generic[BedKind], ContextManager): - """A writer of BED records. - - Args: - handle: An open file-like object to write to. - - Attributes: - bed_kind: The kind of BED type that this writer will write. - - """ - - bed_kind: type[BedKind] | None - - def __class_getitem__(cls, key: object) -> type: - """Wrap all objects of this class to become generic aliases.""" - return _GenericAlias(cls, key) # type: ignore[no-any-return] - - def __new__(cls, handle: io.TextIOWrapper) -> "BedWriter[BedKind]": - """Bind the kind of BED type to this class for later introspection.""" - signature = cast(FrameType, cast(FrameType, inspect.currentframe()).f_back) - typelevel = signature.f_locals.get("self", None) - bed_kind = None if typelevel is None else typelevel.__args__[0] - instance = super().__new__(cls) - instance.bed_kind = bed_kind - return instance - - def __enter__(self) -> "BedWriter[BedKind]": - """Enter this context.""" - return self - - def __init__(self, handle: io.TextIOWrapper) -> None: - """Initialize a BED writer wihout knowing yet what BED types we will write.""" - self._handle = handle - - def __exit__( - self, - __exc_type: type[BaseException] | None, - __exc_value: BaseException | None, - __traceback: TracebackType | None, - ) -> bool | None: - """Close and exit this context.""" - self.close() - return super().__exit__(__exc_type, __exc_value, __traceback) - - @classmethod_generic - def from_path(cls, path: Path | str) -> "BedWriter[BedKind]": - """Open a BED reader from a file path.""" - reader = cls(handle=Path(path).open("w")) # type: ignore[operator] - reader.bed_kind = None if len(cls.__args__) == 0 else cls.__args__[0] # type: ignore[attr-defined] - return cast("BedWriter[BedKind]", reader) - - def close(self) -> None: - """Close the underlying IO handle.""" - self._handle.close() - - def write_comment(self, comment: str) -> None: - """Write a comment to the BED output.""" - for line in comment.splitlines(): - if any(line.startswith(prefix) for prefix in COMMENT_PREFIXES): - self._handle.write(f"{comment}\n") - else: - self._handle.write(f"# {comment}\n") - - def write(self, bed: BedKind) -> None: - """Write a BED record to the BED output.""" - if self.bed_kind is not None: - if type(bed) is not self.bed_kind: - raise TypeError( - f"BedWriter can only continue to write features of the same type." - f" Will not write a {type(bed).__name__} after a {self.bed_kind.__name__}." - ) - else: - self.bed_kind = type(bed) - - self._handle.write(f"{"\t".join(map(str, as_dict(bed).values()))}\n") - - def write_all(self, beds: Iterable[BedKind]) -> None: - """Write all the BED records to the BED output.""" - for bed in beds: - self.write(bed) - - -class BedReader(Generic[BedKind], ContextManager, Iterable[BedKind]): - """A reader of BED records. - - This reader is capable of reading BED records but must be typed at runtime: - - ```python - from bedspec import BedReader, Bed3 - - with BedReader[Bed3](path) as reader: - print(list(reader) - ``` - - Args: - handle: An open file-like object to read from. - - Attributes: - bed_kind: The kind of BED type that this reader will read. - - """ - - bed_kind: type[BedKind] | None - - def __class_getitem__(cls, key: object) -> type: - """Wrap all objects of this class to become generic aliases.""" - return _GenericAlias(cls, key) # type: ignore[no-any-return] - - def __new__(cls, handle: io.TextIOWrapper) -> "BedReader[BedKind]": - """Bind the kind of BED type to this class for later introspection.""" - signature = cast(FrameType, cast(FrameType, inspect.currentframe()).f_back) - typelevel = signature.f_locals.get("self", None) - bed_kind = None if typelevel is None else typelevel.__args__[0] - instance = super().__new__(cls) - instance.bed_kind = bed_kind - return instance - - def __init__(self, handle: io.TextIOWrapper) -> None: - """Initialize a BED reader wihout knowing yet what BED types we will write.""" - self._handle = handle - - def __enter__(self) -> "BedReader[BedKind]": - """Enter this context.""" - return self - - def __iter__(self) -> Iterator[BedKind]: - """Iterate through the BED records of this IO handle.""" - # TODO: Implement __next__ and type this class as an iterator. - if self.bed_kind is None: - raise NotImplementedError("Untyped reading is not yet supported!") - for line in self._handle: - if line.strip() == "": - continue - if any(line.startswith(prefix) for prefix in COMMENT_PREFIXES): - continue - yield cast(BedKind, self.bed_kind.decode(line)) - - def __exit__( - self, - __exc_type: type[BaseException] | None, - __exc_value: BaseException | None, - __traceback: TracebackType | None, - ) -> bool | None: - """Close and exit this context.""" - self.close() - return super().__exit__(__exc_type, __exc_value, __traceback) - - @classmethod_generic - def from_path(cls, path: Path | str) -> "BedReader[BedKind]": - """Open a BED reader from a file path.""" - reader = cls(handle=Path(path).open()) # type: ignore[operator] - reader.bed_kind = None if len(cls.__args__) == 0 else cls.__args__[0] # type: ignore[attr-defined] - return cast("BedReader[BedKind]", reader) - - def close(self) -> None: - """Close the underlying IO handle.""" - self._handle.close() diff --git a/bedspec/_io.py b/bedspec/_io.py new file mode 100644 index 0000000..7b70d83 --- /dev/null +++ b/bedspec/_io.py @@ -0,0 +1,199 @@ +import inspect +import io +from dataclasses import asdict as as_dict +from pathlib import Path +from types import FrameType +from types import TracebackType +from typing import ContextManager +from typing import Generic +from typing import Iterable +from typing import Iterator +from typing import TypeVar +from typing import _GenericAlias # type: ignore[attr-defined] +from typing import cast + +import bedspec._typing + +#################################################################################################### + +BED_EXTENSION: str = ".bed" +"""The default file extension for BED files.""" + +BEDGRAPH_EXTENSION: str = ".bedgraph" +"""The default file extension for bedGraph files.""" + +BEDPE_EXTENSION: str = ".bedpe" +"""The default file extension for BedPE files.""" + +TRACK_EXTENSION: str = ".track" +"""The default file extension for UCSC track files.""" + +#################################################################################################### + +COMMENT_PREFIXES: set[str] = {"#", "browser", "track"} +"""The set of BED comment prefixes that this library supports.""" + +MISSING_FIELD: str = "." +"""The string used to indicate a missing field in a BED record.""" + +#################################################################################################### + +BedKind = TypeVar("BedKind", bound=bedspec._types.BedType) +"""A type variable for any kind of BED record type.""" + + +class BedWriter(Generic[BedKind], ContextManager): + """A writer of BED records. + + Args: + handle: An open file-like object to write to. + + Attributes: + bed_kind: The kind of BED type that this writer will write. + + """ + + bed_kind: type[BedKind] | None + + def __class_getitem__(cls, key: object) -> type: + """Wrap all objects of this class to become generic aliases.""" + return _GenericAlias(cls, key) # type: ignore[no-any-return] + + def __new__(cls, handle: io.TextIOWrapper) -> "BedWriter[BedKind]": + """Bind the kind of BED type to this class for later introspection.""" + signature = cast(FrameType, cast(FrameType, inspect.currentframe()).f_back) + typelevel = signature.f_locals.get("self", None) + bed_kind = None if typelevel is None else typelevel.__args__[0] + instance = super().__new__(cls) + instance.bed_kind = bed_kind + return instance + + def __enter__(self) -> "BedWriter[BedKind]": + """Enter this context.""" + return self + + def __init__(self, handle: io.TextIOWrapper) -> None: + """Initialize a BED writer without knowing yet what BED types we will write.""" + self._handle = handle + + def __exit__( + self, + __exc_type: type[BaseException] | None, + __exc_value: BaseException | None, + __traceback: TracebackType | None, + ) -> bool | None: + """Close and exit this context.""" + self.close() + return super().__exit__(__exc_type, __exc_value, __traceback) + + @bedspec._typing.classmethod_generic + def from_path(cls, path: Path | str) -> "BedWriter[BedKind]": + """Open a BED writer from a file path.""" + reader = cls(handle=Path(path).open("w")) # type: ignore[operator] + reader.bed_kind = None if len(cls.__args__) == 0 else cls.__args__[0] # type: ignore[attr-defined] + return cast("BedWriter[BedKind]", reader) + + def close(self) -> None: + """Close the underlying IO handle.""" + self._handle.close() + + def write_comment(self, comment: str) -> None: + """Write a comment to the BED output.""" + for line in comment.splitlines(): + if any(line.startswith(prefix) for prefix in COMMENT_PREFIXES): + self._handle.write(f"{comment}\n") + else: + self._handle.write(f"# {comment}\n") + + def write(self, bed: BedKind) -> None: + """Write a BED record to the BED output.""" + if self.bed_kind is not None: + if type(bed) is not self.bed_kind: + raise TypeError( + f"BedWriter can only continue to write features of the same type." + f" Will not write a {type(bed).__name__} after a {self.bed_kind.__name__}." + ) + else: + self.bed_kind = type(bed) + + self._handle.write(f"{"\t".join(map(str, as_dict(bed).values()))}\n") + + def write_all(self, beds: Iterable[BedKind]) -> None: + """Write all the BED records to the BED output.""" + for bed in beds: + self.write(bed) + + +class BedReader(Generic[BedKind], ContextManager, Iterable[BedKind]): + """A reader of BED records. + + This reader is capable of reading BED records but must be typed at runtime: + + ```python + from bedspec import BedReader, Bed3 + + with BedReader[Bed3](path) as reader: + print(list(reader) + ``` + + Args: + handle: An open file-like object to read from. + + Attributes: + bed_kind: The kind of BED type that this reader will read. + + """ + + bed_kind: type[BedKind] | None + + def __class_getitem__(cls, key: object) -> type: + """Wrap all objects of this class to become generic aliases.""" + return _GenericAlias(cls, key) # type: ignore[no-any-return] + + def __new__(cls, handle: io.TextIOWrapper) -> "BedReader[BedKind]": + """Bind the kind of BED type to this class for later introspection.""" + signature = cast(FrameType, cast(FrameType, inspect.currentframe()).f_back) + typelevel = signature.f_locals.get("self", None) + bed_kind = None if typelevel is None else typelevel.__args__[0] + instance = super().__new__(cls) + instance.bed_kind = bed_kind + return instance + + def __init__(self, handle: io.TextIOWrapper) -> None: + """Initialize a BED reader without knowing yet what BED types we will write.""" + self._handle = handle + + def __enter__(self) -> "BedReader[BedKind]": + """Enter this context.""" + return self + + def __iter__(self) -> Iterator[BedKind]: + """Iterate through the BED records of this IO handle.""" + # TODO: Implement __next__ and type this class as an iterator. + if self.bed_kind is None: + raise NotImplementedError("Untyped reading is not yet supported!") + for line in map(lambda line: line.strip(), self._handle): + if line == "" or any(line.startswith(prefix) for prefix in COMMENT_PREFIXES): + continue + yield cast(BedKind, self.bed_kind.decode(line)) + + def __exit__( + self, + __exc_type: type[BaseException] | None, + __exc_value: BaseException | None, + __traceback: TracebackType | None, + ) -> bool | None: + """Close and exit this context.""" + self.close() + return super().__exit__(__exc_type, __exc_value, __traceback) + + @bedspec._typing.classmethod_generic + def from_path(cls, path: Path | str) -> "BedReader[BedKind]": + """Open a BED reader from a file path.""" + reader = cls(handle=Path(path).open("r")) # type: ignore[operator] + reader.bed_kind = None if len(cls.__args__) == 0 else cls.__args__[0] # type: ignore[attr-defined] + return cast("BedReader[BedKind]", reader) + + def close(self) -> None: + """Close the underlying IO handle.""" + self._handle.close() diff --git a/bedspec/_types.py b/bedspec/_types.py new file mode 100644 index 0000000..3399b71 --- /dev/null +++ b/bedspec/_types.py @@ -0,0 +1,115 @@ +import dataclasses +from abc import ABC +from abc import abstractmethod +from dataclasses import Field +from dataclasses import fields +from enum import StrEnum +from enum import unique +from typing import Any +from typing import ClassVar +from typing import Iterator +from typing import Protocol +from typing import Type +from typing import cast +from typing import get_type_hints +from typing import runtime_checkable + +import bedspec + + +@runtime_checkable +class DataclassInstance(Protocol): + """A protocol for objects that are dataclass instances.""" + + __dataclass_fields__: ClassVar[dict[str, Field[Any]]] + + +@unique +class BedStrand(StrEnum): + """BED strands for forward and reverse orientations.""" + + Positive = "+" + """The positive BED strand.""" + + Negative = "-" + """The negative BED strand.""" + + def opposite(self) -> "BedStrand": + """Return the opposite BED strand.""" + if self is BedStrand.Positive: + return BedStrand.Negative + if self is BedStrand.Negative: + return BedStrand.Positive + + +@runtime_checkable +class GenomicSpan(Protocol): + """A structural protocol for 0-based half-open objects located on a reference sequence.""" + + contig: str + start: int + end: int + + +@runtime_checkable +class Named(Protocol): + """A structural protocol for a named BED type.""" + + name: str | None + + +@runtime_checkable +class Stranded(Protocol): + """A structural protocol for stranded BED types.""" + + strand: BedStrand | None + + +class BedType(ABC, DataclassInstance): + """An abstract base class for all types of BED records.""" + + def __new__(cls, *args: object, **kwargs: object) -> "BedType": + if not dataclasses.is_dataclass(cls): + raise TypeError("You must annotate custom BED class definitions with @dataclass!") + instance: BedType = cast(BedType, object.__new__(cls)) + return instance + + @classmethod + def decode(cls, line: str) -> "BedType": + """Decode a line of text into a BED record.""" + row: list[str] = line.strip().split() + coerced: dict[str, object] = {} + + try: + zipped = list(zip(fields(cls), row, strict=True)) + except ValueError as exception: + raise ValueError( + f"Expected {len(fields(cls))} fields but found {len(row)} in record:" + f" '{' '.join(row)}'" + ) from exception + + hints: dict[str, Type] = get_type_hints(cls) + + for field, value in zipped: + try: + if ( + bedspec._typing.is_optional(hints[field.name]) + and value == bedspec._io.MISSING_FIELD + ): + coerced[field.name] = None + else: + coerced[field.name] = bedspec._typing.singular_non_optional_type(field.type)( + value + ) + except ValueError as exception: + raise TypeError( + f"Tried to build the BED field '{field.name}' (of type '{field.type.__name__}')" + f" from the value '{value}' but couldn't for record '{' '.join(row)}'" + ) from exception + + return cls(**coerced) + + @abstractmethod + def territory(self) -> Iterator[GenomicSpan]: + """Return intervals that describe the territory of this BED record.""" + raise NotImplementedError("Method not yet implemented!") diff --git a/bedspec/_typing.py b/bedspec/_typing.py new file mode 100644 index 0000000..111e672 --- /dev/null +++ b/bedspec/_typing.py @@ -0,0 +1,62 @@ +from types import UnionType +from typing import Callable +from typing import Type +from typing import Union +from typing import _BaseGenericAlias # type: ignore[attr-defined] +from typing import get_args +from typing import get_origin + + +class MethodType: + def __init__(self, func: Callable, obj: object) -> None: + self.__func__ = func + self.__self__ = obj + + def __call__(self, *args: object, **kwargs: object) -> object: + func = self.__func__ + obj = self.__self__ + return func(obj, *args, **kwargs) + + +class classmethod_generic: + def __init__(self, f: Callable) -> None: + self.f = f + + def __get__(self, obj: object, cls: object | None = None) -> Callable: + if cls is None: + cls = type(obj) + method = MethodType(self.f, cls) + method._generic_classmethod = True # type: ignore[attr-defined] + return method + + +def __getattr__(self: object, name: str | None = None) -> object: + if hasattr(obj := orig_getattr(self, name), "_generic_classmethod"): + obj.__self__ = self + return obj + + +orig_getattr = _BaseGenericAlias.__getattr__ +_BaseGenericAlias.__getattr__ = __getattr__ + + +def is_union(annotation: Type) -> bool: + """Test if we have a union type annotation or not.""" + return get_origin(annotation) in {Union, UnionType} + + +def is_optional(annotation: Type) -> bool: + """Return if this type annotation is optional (a union type with None) or not.""" + return is_union(annotation) and type(None) in get_args(annotation) + + +def singular_non_optional_type(annotation: Type) -> Type: + """Return the non-optional version of a singular type annotation.""" + if not is_optional(annotation): + return annotation + + not_none: list[Type] = [arg for arg in get_args(annotation) if arg is not type(None)] + if len(not_none) == 1: + return not_none[0] + else: + raise TypeError(f"Complex non-optional types are not supported! Found: {not_none}") diff --git a/bedspec/overlap/__init__.py b/bedspec/overlap/__init__.py new file mode 100644 index 0000000..0f3a053 --- /dev/null +++ b/bedspec/overlap/__init__.py @@ -0,0 +1,3 @@ +# ruff: noqa: F401 +from ._overlap import GenomicSpanLike +from ._overlap import OverlapDetector diff --git a/bedspec/overlap/_overlap.py b/bedspec/overlap/_overlap.py new file mode 100644 index 0000000..673a596 --- /dev/null +++ b/bedspec/overlap/_overlap.py @@ -0,0 +1,149 @@ +import itertools +from collections import defaultdict +from typing import Generic +from typing import Hashable +from typing import Iterable +from typing import Iterator +from typing import Protocol +from typing import TypeAlias +from typing import TypeVar + +import bedspec +import cgranges as cr + + +class _Span(Hashable, Protocol): + """A span with a start and an end. 0-based open-ended.""" + + @property + def start(self) -> int: + """A 0-based start position.""" + + @property + def end(self) -> int: + """A 0-based open-ended position.""" + + +class _GenomicSpanWithChrom(_Span, Protocol): + """A genomic feature where reference sequence is accessed with `chrom`.""" + + @property + def chrom(self) -> str: + """A reference sequence name.""" + + +class _GenomicSpanWithContig(_Span, Protocol): + """A genomic feature where reference sequence is accessed with `contig`.""" + + @property + def contig(self) -> str: + """A reference sequence name.""" + + +class _GenomicSpanWithRefName(_Span, Protocol): + """A genomic feature where reference sequence is accessed with `refname`.""" + + @property + def refname(self) -> str: + """A reference sequence name.""" + + +GenomicSpanLike = TypeVar( + "GenomicSpanLike", + bound=_GenomicSpanWithChrom | _GenomicSpanWithContig | _GenomicSpanWithRefName, +) +""" +A 0-based exclusive genomic feature where the reference sequence name is accessed with any of the 3 +most common property names ("chrom", "contig", "refname"). +""" + +_GenericGenomicSpanLike = TypeVar( + "_GenericGenomicSpanLike", + bound=_GenomicSpanWithChrom | _GenomicSpanWithContig | _GenomicSpanWithRefName, +) +""" +A generic 0-based exclusive genomic feature where the reference sequence name is accessed with any +of the most common property names ("chrom", "contig", "refname"). This type variable is used for +describing the generic type contained within the :class:`~bedspec.overlap.OverlapDetector`. +""" + +Refname: TypeAlias = str +"""A type alias for a reference sequence name string.""" + + +class OverlapDetector(Generic[_GenericGenomicSpanLike], Iterable[_GenericGenomicSpanLike]): + """Detects and returns overlaps between a collection of genomic features and an interval. + + The overlap detector may be built with any genomic feature-like Python object that has the + following properties: + + * `chrom` or `contig` or `refname`: The reference sequence name + * `start`: A 0-based start position + * `end`: A 0-based exclusive end position + + This detector is most efficiently used when all features to be queried are added ahead of time. + + """ + + def __init__(self, features: Iterable[_GenericGenomicSpanLike] | None = None) -> None: + self._refname_to_features: dict[Refname, list[_GenericGenomicSpanLike]] = defaultdict(list) + self._refname_to_tree: dict[Refname, cr.cgranges] = defaultdict(cr.cgranges) # type: ignore[attr-defined,name-defined] + self._refname_to_is_indexed: dict[Refname, bool] = defaultdict(lambda: False) + if features is not None: + self.add_all(features) + + def __iter__(self) -> Iterator[_GenericGenomicSpanLike]: + """Iterate over the features in the overlap detector.""" + return itertools.chain(*self._refname_to_features.values()) + + @staticmethod + def _reference_sequence_name(feature: GenomicSpanLike) -> Refname: + """Return the reference name of a given genomic feature.""" + if isinstance(feature, bedspec.GenomicSpan) or hasattr(feature, "contig"): + return feature.contig + if hasattr(feature, "chrom"): + return feature.chrom + elif hasattr(feature, "refname"): + return feature.refname + else: + raise ValueError( + f"Genomic feature is missing a reference sequence name property: {feature}" + ) + + def _maybe_index_tree(self, refname: Refname) -> None: + """Index a tree for a given reference sequence name if necessary.""" + if refname in self._refname_to_tree and not self._refname_to_is_indexed[refname]: + self._refname_to_tree[refname].index() + + def add(self, feature: _GenericGenomicSpanLike) -> None: + """Add a genomic feature to this overlap detector.""" + if not isinstance(feature, Hashable): + raise ValueError(f"Genomic feature is not hashable but should be: {feature}") + + refname: Refname = self._reference_sequence_name(feature) + feature_idx: int = len(self._refname_to_features[refname]) + + self._refname_to_features[refname].append(feature) + self._refname_to_tree[refname].add(refname, feature.start, feature.end, feature_idx) + self._refname_to_is_indexed[refname] = False # mark that this tree needs re-indexing + + def add_all(self, features: Iterable[_GenericGenomicSpanLike]) -> None: + """Adds one or more genomic features to this overlap detector.""" + for feature in features: + self.add(feature) + + def get_overlapping(self, feature: GenomicSpanLike) -> Iterator[_GenericGenomicSpanLike]: + """Yields all the overlapping features for a given genomic span.""" + refname: Refname = self._reference_sequence_name(feature) + self._maybe_index_tree(refname) + + for *_, idx in self._refname_to_tree[refname].overlap(refname, feature.start, feature.end): + yield self._refname_to_features[refname][idx] + + def overlaps_any(self, feature: GenomicSpanLike) -> bool: + """Determine if a given genomic span overlaps any features.""" + try: + next(self.get_overlapping(feature)) + return True + except StopIteration: + return False diff --git a/poetry.lock b/poetry.lock index 8853f9a..c4f0c1b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -32,63 +32,63 @@ files = [ [[package]] name = "coverage" -version = "7.4.4" +version = "7.5.4" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0be5efd5127542ef31f165de269f77560d6cdef525fffa446de6f7e9186cfb2"}, - {file = "coverage-7.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ccd341521be3d1b3daeb41960ae94a5e87abe2f46f17224ba5d6f2b8398016cf"}, - {file = "coverage-7.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fa497a8ab37784fbb20ab699c246053ac294d13fc7eb40ec007a5043ec91f8"}, - {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1a93009cb80730c9bca5d6d4665494b725b6e8e157c1cb7f2db5b4b122ea562"}, - {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:690db6517f09336559dc0b5f55342df62370a48f5469fabf502db2c6d1cffcd2"}, - {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:09c3255458533cb76ef55da8cc49ffab9e33f083739c8bd4f58e79fecfe288f7"}, - {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8ce1415194b4a6bd0cdcc3a1dfbf58b63f910dcb7330fe15bdff542c56949f87"}, - {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b91cbc4b195444e7e258ba27ac33769c41b94967919f10037e6355e998af255c"}, - {file = "coverage-7.4.4-cp310-cp310-win32.whl", hash = "sha256:598825b51b81c808cb6f078dcb972f96af96b078faa47af7dfcdf282835baa8d"}, - {file = "coverage-7.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:09ef9199ed6653989ebbcaacc9b62b514bb63ea2f90256e71fea3ed74bd8ff6f"}, - {file = "coverage-7.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f9f50e7ef2a71e2fae92774c99170eb8304e3fdf9c8c3c7ae9bab3e7229c5cf"}, - {file = "coverage-7.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:623512f8ba53c422fcfb2ce68362c97945095b864cda94a92edbaf5994201083"}, - {file = "coverage-7.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0513b9508b93da4e1716744ef6ebc507aff016ba115ffe8ecff744d1322a7b63"}, - {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40209e141059b9370a2657c9b15607815359ab3ef9918f0196b6fccce8d3230f"}, - {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a2b2b78c78293782fd3767d53e6474582f62443d0504b1554370bde86cc8227"}, - {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:73bfb9c09951125d06ee473bed216e2c3742f530fc5acc1383883125de76d9cd"}, - {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1f384c3cc76aeedce208643697fb3e8437604b512255de6d18dae3f27655a384"}, - {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:54eb8d1bf7cacfbf2a3186019bcf01d11c666bd495ed18717162f7eb1e9dd00b"}, - {file = "coverage-7.4.4-cp311-cp311-win32.whl", hash = "sha256:cac99918c7bba15302a2d81f0312c08054a3359eaa1929c7e4b26ebe41e9b286"}, - {file = "coverage-7.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:b14706df8b2de49869ae03a5ccbc211f4041750cd4a66f698df89d44f4bd30ec"}, - {file = "coverage-7.4.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:201bef2eea65e0e9c56343115ba3814e896afe6d36ffd37bab783261db430f76"}, - {file = "coverage-7.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41c9c5f3de16b903b610d09650e5e27adbfa7f500302718c9ffd1c12cf9d6818"}, - {file = "coverage-7.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d898fe162d26929b5960e4e138651f7427048e72c853607f2b200909794ed978"}, - {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ea79bb50e805cd6ac058dfa3b5c8f6c040cb87fe83de10845857f5535d1db70"}, - {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce4b94265ca988c3f8e479e741693d143026632672e3ff924f25fab50518dd51"}, - {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:00838a35b882694afda09f85e469c96367daa3f3f2b097d846a7216993d37f4c"}, - {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48"}, - {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:69eb372f7e2ece89f14751fbcbe470295d73ed41ecd37ca36ed2eb47512a6ab9"}, - {file = "coverage-7.4.4-cp312-cp312-win32.whl", hash = "sha256:137eb07173141545e07403cca94ab625cc1cc6bc4c1e97b6e3846270e7e1fea0"}, - {file = "coverage-7.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:d71eec7d83298f1af3326ce0ff1d0ea83c7cb98f72b577097f9083b20bdaf05e"}, - {file = "coverage-7.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d5ae728ff3b5401cc320d792866987e7e7e880e6ebd24433b70a33b643bb0384"}, - {file = "coverage-7.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc4f1358cb0c78edef3ed237ef2c86056206bb8d9140e73b6b89fbcfcbdd40e1"}, - {file = "coverage-7.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8130a2aa2acb8788e0b56938786c33c7c98562697bf9f4c7d6e8e5e3a0501e4a"}, - {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf271892d13e43bc2b51e6908ec9a6a5094a4df1d8af0bfc360088ee6c684409"}, - {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4cdc86d54b5da0df6d3d3a2f0b710949286094c3a6700c21e9015932b81447e"}, - {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ae71e7ddb7a413dd60052e90528f2f65270aad4b509563af6d03d53e979feafd"}, - {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:38dd60d7bf242c4ed5b38e094baf6401faa114fc09e9e6632374388a404f98e7"}, - {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa5b1c1bfc28384f1f53b69a023d789f72b2e0ab1b3787aae16992a7ca21056c"}, - {file = "coverage-7.4.4-cp38-cp38-win32.whl", hash = "sha256:dfa8fe35a0bb90382837b238fff375de15f0dcdb9ae68ff85f7a63649c98527e"}, - {file = "coverage-7.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:b2991665420a803495e0b90a79233c1433d6ed77ef282e8e152a324bbbc5e0c8"}, - {file = "coverage-7.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b799445b9f7ee8bf299cfaed6f5b226c0037b74886a4e11515e569b36fe310d"}, - {file = "coverage-7.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b4d33f418f46362995f1e9d4f3a35a1b6322cb959c31d88ae56b0298e1c22357"}, - {file = "coverage-7.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadacf9a2f407a4688d700e4ebab33a7e2e408f2ca04dbf4aef17585389eff3e"}, - {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c95949560050d04d46b919301826525597f07b33beba6187d04fa64d47ac82e"}, - {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4"}, - {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5fc1de20b2d4a061b3df27ab9b7c7111e9a710f10dc2b84d33a4ab25065994ec"}, - {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c74880fc64d4958159fbd537a091d2a585448a8f8508bf248d72112723974cbd"}, - {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:742a76a12aa45b44d236815d282b03cfb1de3b4323f3e4ec933acfae08e54ade"}, - {file = "coverage-7.4.4-cp39-cp39-win32.whl", hash = "sha256:d89d7b2974cae412400e88f35d86af72208e1ede1a541954af5d944a8ba46c57"}, - {file = "coverage-7.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:9ca28a302acb19b6af89e90f33ee3e1906961f94b54ea37de6737b7ca9d8827c"}, - {file = "coverage-7.4.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:b2c5edc4ac10a7ef6605a966c58929ec6c1bd0917fb8c15cb3363f65aa40e677"}, - {file = "coverage-7.4.4.tar.gz", hash = "sha256:c901df83d097649e257e803be22592aedfd5182f07b3cc87d640bbb9afd50f49"}, + {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"}, ] [package.extras] @@ -96,18 +96,18 @@ toml = ["tomli"] [[package]] name = "filelock" -version = "3.13.4" +version = "3.15.4" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ - {file = "filelock-3.13.4-py3-none-any.whl", hash = "sha256:404e5e9253aa60ad457cae1be07c0f0ca90a63931200a47d9b6a6af84fd7b45f"}, - {file = "filelock-3.13.4.tar.gz", hash = "sha256:d13f466618bfde72bd2c18255e269f72542c6e70e7bac83a0232d6b1cc5c8cf4"}, + {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"}, + {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"}, ] [package.extras] docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"] typing = ["typing-extensions (>=4.8)"] [[package]] @@ -123,38 +123,38 @@ files = [ [[package]] name = "mypy" -version = "1.9.0" +version = "1.10.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f"}, - {file = "mypy-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed"}, - {file = "mypy-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150"}, - {file = "mypy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374"}, - {file = "mypy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03"}, - {file = "mypy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3"}, - {file = "mypy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc"}, - {file = "mypy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129"}, - {file = "mypy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612"}, - {file = "mypy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3"}, - {file = "mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd"}, - {file = "mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6"}, - {file = "mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185"}, - {file = "mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913"}, - {file = "mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"}, - {file = "mypy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b"}, - {file = "mypy-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2"}, - {file = "mypy-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e"}, - {file = "mypy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04"}, - {file = "mypy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89"}, - {file = "mypy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02"}, - {file = "mypy-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4"}, - {file = "mypy-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d"}, - {file = "mypy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf"}, - {file = "mypy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9"}, - {file = "mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e"}, - {file = "mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974"}, + {file = "mypy-1.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e36f229acfe250dc660790840916eb49726c928e8ce10fbdf90715090fe4ae02"}, + {file = "mypy-1.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:51a46974340baaa4145363b9e051812a2446cf583dfaeba124af966fa44593f7"}, + {file = "mypy-1.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:901c89c2d67bba57aaaca91ccdb659aa3a312de67f23b9dfb059727cce2e2e0a"}, + {file = "mypy-1.10.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0cd62192a4a32b77ceb31272d9e74d23cd88c8060c34d1d3622db3267679a5d9"}, + {file = "mypy-1.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:a2cbc68cb9e943ac0814c13e2452d2046c2f2b23ff0278e26599224cf164e78d"}, + {file = "mypy-1.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bd6f629b67bb43dc0d9211ee98b96d8dabc97b1ad38b9b25f5e4c4d7569a0c6a"}, + {file = "mypy-1.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1bbb3a6f5ff319d2b9d40b4080d46cd639abe3516d5a62c070cf0114a457d84"}, + {file = "mypy-1.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8edd4e9bbbc9d7b79502eb9592cab808585516ae1bcc1446eb9122656c6066f"}, + {file = "mypy-1.10.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6166a88b15f1759f94a46fa474c7b1b05d134b1b61fca627dd7335454cc9aa6b"}, + {file = "mypy-1.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:5bb9cd11c01c8606a9d0b83ffa91d0b236a0e91bc4126d9ba9ce62906ada868e"}, + {file = "mypy-1.10.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d8681909f7b44d0b7b86e653ca152d6dff0eb5eb41694e163c6092124f8246d7"}, + {file = "mypy-1.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:378c03f53f10bbdd55ca94e46ec3ba255279706a6aacaecac52ad248f98205d3"}, + {file = "mypy-1.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bacf8f3a3d7d849f40ca6caea5c055122efe70e81480c8328ad29c55c69e93e"}, + {file = "mypy-1.10.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:701b5f71413f1e9855566a34d6e9d12624e9e0a8818a5704d74d6b0402e66c04"}, + {file = "mypy-1.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c4c2992f6ea46ff7fce0072642cfb62af7a2484efe69017ed8b095f7b39ef31"}, + {file = "mypy-1.10.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:604282c886497645ffb87b8f35a57ec773a4a2721161e709a4422c1636ddde5c"}, + {file = "mypy-1.10.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37fd87cab83f09842653f08de066ee68f1182b9b5282e4634cdb4b407266bade"}, + {file = "mypy-1.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8addf6313777dbb92e9564c5d32ec122bf2c6c39d683ea64de6a1fd98b90fe37"}, + {file = "mypy-1.10.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5cc3ca0a244eb9a5249c7c583ad9a7e881aa5d7b73c35652296ddcdb33b2b9c7"}, + {file = "mypy-1.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:1b3a2ffce52cc4dbaeee4df762f20a2905aa171ef157b82192f2e2f368eec05d"}, + {file = "mypy-1.10.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fe85ed6836165d52ae8b88f99527d3d1b2362e0cb90b005409b8bed90e9059b3"}, + {file = "mypy-1.10.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2ae450d60d7d020d67ab440c6e3fae375809988119817214440033f26ddf7bf"}, + {file = "mypy-1.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6be84c06e6abd72f960ba9a71561c14137a583093ffcf9bbfaf5e613d63fa531"}, + {file = "mypy-1.10.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2189ff1e39db399f08205e22a797383613ce1cb0cb3b13d8bcf0170e45b96cc3"}, + {file = "mypy-1.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:97a131ee36ac37ce9581f4220311247ab6cba896b4395b9c87af0675a13a755f"}, + {file = "mypy-1.10.1-py3-none-any.whl", hash = "sha256:71d8ac0b906354ebda8ef1673e5fde785936ac1f29ff6987c7483cfbd5a4235a"}, + {file = "mypy-1.10.1.tar.gz", hash = "sha256:1f8f492d7db9e3593ef42d4f115f04e556130f2819ad33ab84551403e97dd4c0"}, ] [package.dependencies] @@ -180,24 +180,24 @@ files = [ [[package]] name = "packaging" -version = "24.0" +version = "24.1" description = "Core utilities for Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, - {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] [[package]] name = "pluggy" -version = "1.4.0" +version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" files = [ - {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, - {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] @@ -242,6 +242,25 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] +[[package]] +name = "pytest-doctestplus" +version = "1.2.1" +description = "Pytest plugin with advanced doctest features." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-doctestplus-1.2.1.tar.gz", hash = "sha256:2472a8a2c8cea34d2f65f6499543faeb748eecb59c597852fd98839b47307679"}, + {file = "pytest_doctestplus-1.2.1-py3-none-any.whl", hash = "sha256:103705daee8d4468eb59d444c29b0d71eb85b8f6d582295c8bc3d68ee1d88911"}, +] + +[package.dependencies] +packaging = ">=17.0" +pytest = ">=4.6" +setuptools = ">=30.3.0" + +[package.extras] +test = ["numpy", "pytest-remotedata (>=0.3.2)", "sphinx"] + [[package]] name = "pytest-mypy" version = "0.10.3" @@ -299,18 +318,33 @@ files = [ {file = "ruff-0.1.13.tar.gz", hash = "sha256:e261f1baed6291f434ffb1d5c6bd8051d1c2a26958072d38dfbec39b3dda7352"}, ] +[[package]] +name = "setuptools" +version = "70.1.1" +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"}, +] + +[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"] + [[package]] name = "typing-extensions" -version = "4.11.0" +version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, - {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] [metadata] lock-version = "2.0" -python-versions = "^3.12" -content-hash = "5ab9558b5c8d7eae824438a1eee7bb7eb45098561d805fef1fe70be5d7ad67a9" +python-versions = "^3.12.0" +content-hash = "c4a10a57d529eb9cbdd7a445aafb424ea58b2a150c9959847c5a74da565ef334" diff --git a/pyproject.toml b/pyproject.toml index e88883b..5ed7408 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,12 +24,13 @@ include = ["CONTRIBUTING.md", "LICENSE"] packages = [{ include = "bedspec" }, { include = "cgranges" }] [tool.poetry.dependencies] -python = "^3.12" +python = "^3.12.0" [tool.poetry.dev-dependencies] mypy = "^1.8" pytest = "^7.4" pytest-cov = "^4.1" +pytest-doctestplus = "^1.2.1" pytest-mypy = "^0.10" pytest-ruff = "^0.2" ruff = "0.1.13" @@ -39,7 +40,7 @@ script = "build.py" generate-setup-file = true [build-system] -requires = ["poetry-core>=1.6", "setuptools", "cython"] +requires = ["poetry-core>=1.6", "setuptools", "Cython"] build-backend = "poetry.core.masonry.api" [tool.mypy] @@ -72,9 +73,11 @@ addopts = [ "--color=yes", "--import-mode=importlib", "--cov", + "--doctest-plus", "--mypy", "--ruff", "--ignore=cgranges/", + "--ignore=build.py", ] [tool.ruff] diff --git a/tests/test_bedspec.py b/tests/test_bedspec.py index 1564174..1fb6361 100644 --- a/tests/test_bedspec.py +++ b/tests/test_bedspec.py @@ -1,96 +1,28 @@ -import dataclasses from dataclasses import dataclass -from pathlib import Path -from typing import Iterator import pytest -from bedspec import MISSING_FIELD from bedspec import Bed2 from bedspec import Bed3 from bedspec import Bed4 from bedspec import Bed5 from bedspec import Bed6 -from bedspec import BedColor +from bedspec import BedGraph from bedspec import BedPE -from bedspec import BedReader from bedspec import BedStrand -from bedspec import BedType -from bedspec import BedWriter -from bedspec import Locatable from bedspec import PairBed from bedspec import PointBed from bedspec import SimpleBed -from bedspec import Stranded -from bedspec._bedspec import is_union - - -def test_is_union() -> None: - """Test that a union type is a union type.""" - # TODO: have a positive unit test for is_union - assert not is_union(type(int)) - assert not is_union(type(None)) - - -def test_bed_strand() -> None: - """Test that BED strands behave as string.""" - assert BedStrand("+") == BedStrand.POSITIVE - assert BedStrand("-") == BedStrand.NEGATIVE - assert str(BedStrand.POSITIVE) == "+" - assert str(BedStrand.NEGATIVE) == "-" - - -def test_bed_strand_opposite() -> None: - """Test that we return an opposite BED strand.""" - assert BedStrand.POSITIVE.opposite() == BedStrand.NEGATIVE - assert BedStrand.NEGATIVE.opposite() == BedStrand.POSITIVE - - -def test_bed_color() -> None: - """Test the small helper class for BED color.""" - assert str(BedColor(2, 3, 4)) == "2,3,4" - - -def test_bed_type_class_hierarchy() -> None: - """Test that all abstract base classes are subclasses of Bedtype.""" - for subclass in (PointBed, SimpleBed, PairBed): - assert issubclass(subclass, BedType) - - -@pytest.mark.parametrize("bed_type", (Bed2, Bed3, Bed4, Bed5, Bed6, BedPE)) -def test_all_bed_types_are_dataclasses(bed_type: type[BedType]) -> None: - """Test that a simple BED record behaves as expected.""" - assert dataclasses.is_dataclass(bed_type) - - -def test_locatable_structural_type() -> None: - """Test that the Locatable structural type is set correctly.""" - _: Locatable = Bed6( - contig="chr1", start=1, end=2, name="foo", score=3, strand=BedStrand.POSITIVE - ) - - -def test_stranded_structural_type() -> None: - """Test that the Stranded structural type is set correctly.""" - _: Stranded = Bed6( - contig="chr1", start=1, end=2, name="foo", score=3, strand=BedStrand.POSITIVE - ) - - -def test_dataclass_protocol_structural_type() -> None: - """Test that the dataclass structural type is set correctly.""" - from bedspec._bedspec import DataclassProtocol - - _: DataclassProtocol = Bed2(contig="chr1", start=1) def test_instantiating_all_bed_types() -> None: - """Test that we can instantiate all BED types.""" + """Test that we can instantiate all builtin BED types.""" Bed2(contig="chr1", start=1) Bed3(contig="chr1", start=1, end=2) Bed4(contig="chr1", start=1, end=2, name="foo") Bed5(contig="chr1", start=1, end=2, name="foo", score=3) - Bed6(contig="chr1", start=1, end=2, name="foo", score=3, strand=BedStrand.POSITIVE) + Bed6(contig="chr1", start=1, end=2, name="foo", score=3, strand=BedStrand.Positive) + BedGraph(contig="chr1", start=1, end=2, value=0.2) BedPE( contig1="chr1", start1=1, @@ -100,8 +32,8 @@ def test_instantiating_all_bed_types() -> None: end2=4, name="foo", score=5, - strand1=BedStrand.POSITIVE, - strand2=BedStrand.NEGATIVE, + strand1=BedStrand.Positive, + strand2=BedStrand.Negative, ) @@ -116,11 +48,11 @@ def test_paired_bed_has_two_interval_properties() -> None: end2=4, name="foo", score=5, - strand1=BedStrand.POSITIVE, - strand2=BedStrand.NEGATIVE, + strand1=BedStrand.Positive, + strand2=BedStrand.Negative, ) - assert record.bed1 == Bed6(contig="chr1", start=1, end=2, name="foo", score=5, strand=BedStrand.POSITIVE) # fmt: skip # noqa: E501 - assert record.bed2 == Bed6(contig="chr2", start=3, end=4, name="foo", score=5, strand=BedStrand.NEGATIVE) # fmt: skip # noqa: E501 + assert record.bed1 == Bed6(contig="chr1", start=1, end=2, name="foo", score=5, strand=BedStrand.Positive) # fmt: skip # noqa: E501 + assert record.bed2 == Bed6(contig="chr2", start=3, end=4, name="foo", score=5, strand=BedStrand.Negative) # fmt: skip # noqa: E501 def test_point_bed_types_have_a_territory() -> None: @@ -140,7 +72,8 @@ def test_simple_bed_types_have_a_territory() -> None: Bed3(contig="chr1", start=1, end=2), Bed4(contig="chr1", start=1, end=2, name="foo"), Bed5(contig="chr1", start=1, end=2, name="foo", score=3), - Bed6(contig="chr1", start=1, end=2, name="foo", score=3, strand=BedStrand.POSITIVE), + Bed6(contig="chr1", start=1, end=2, name="foo", score=3, strand=BedStrand.Positive), + BedGraph(contig="chr1", start=1, end=2, value=1.0), ): assert list(record.territory()) == [record] @@ -163,25 +96,25 @@ def test_simple_bed_validates_start_and_end() -> None: def test_paired_bed_validates_start_and_end() -> None: - """Test that a simple BED record validates its start and end.""" + """Test a paired BED record validates its start and end for both intervals.""" # fmt: off with pytest.raises(ValueError): - BedPE(contig1="chr1", start1=-1, end1=5, contig2="chr1", start2=1, end2=2, name="foo", score=5, strand1=BedStrand.POSITIVE, strand2=BedStrand.POSITIVE) # noqa: E501 + BedPE(contig1="chr1", start1=-1, end1=5, contig2="chr1", start2=1, end2=2, name="foo", score=5, strand1=BedStrand.Positive, strand2=BedStrand.Positive) # noqa: E501 with pytest.raises(ValueError): - BedPE(contig1="chr1", start1=5, end1=5, contig2="chr1", start2=1, end2=2, name="foo", score=5, strand1=BedStrand.POSITIVE, strand2=BedStrand.POSITIVE) # noqa: E501 + BedPE(contig1="chr1", start1=5, end1=5, contig2="chr1", start2=1, end2=2, name="foo", score=5, strand1=BedStrand.Positive, strand2=BedStrand.Positive) # noqa: E501 with pytest.raises(ValueError): - BedPE(contig1="chr1", start1=5, end1=0, contig2="chr1", start2=1, end2=2, name="foo", score=5, strand1=BedStrand.POSITIVE, strand2=BedStrand.POSITIVE) # noqa: E501 + BedPE(contig1="chr1", start1=5, end1=0, contig2="chr1", start2=1, end2=2, name="foo", score=5, strand1=BedStrand.Positive, strand2=BedStrand.Positive) # noqa: E501 with pytest.raises(ValueError): - BedPE(contig1="chr1", start1=1, end1=2, contig2="chr1", start2=-1, end2=5, name="foo", score=5, strand1=BedStrand.POSITIVE, strand2=BedStrand.POSITIVE) # noqa: E501 + BedPE(contig1="chr1", start1=1, end1=2, contig2="chr1", start2=-1, end2=5, name="foo", score=5, strand1=BedStrand.Positive, strand2=BedStrand.Positive) # noqa: E501 with pytest.raises(ValueError): - BedPE(contig1="chr1", start1=1, end1=2, contig2="chr1", start2=5, end2=5, name="foo", score=5, strand1=BedStrand.POSITIVE, strand2=BedStrand.POSITIVE) # noqa: E501 + BedPE(contig1="chr1", start1=1, end1=2, contig2="chr1", start2=5, end2=5, name="foo", score=5, strand1=BedStrand.Positive, strand2=BedStrand.Positive) # noqa: E501 with pytest.raises(ValueError): - BedPE(contig1="chr1", start1=1, end1=2, contig2="chr1", start2=5, end2=0, name="foo", score=5, strand1=BedStrand.POSITIVE, strand2=BedStrand.POSITIVE) # noqa: E501 + BedPE(contig1="chr1", start1=1, end1=2, contig2="chr1", start2=5, end2=0, name="foo", score=5, strand1=BedStrand.Positive, strand2=BedStrand.Positive) # noqa: E501 # fmt: on def test_paired_bed_types_have_a_territory() -> None: - """Test that simple BEDs are their own territory.""" + """Test that paired BEDs use both their intervals as their territory.""" record = BedPE( contig1="chr1", start1=1, @@ -191,28 +124,30 @@ def test_paired_bed_types_have_a_territory() -> None: end2=4, name="foo", score=5, - strand1=BedStrand.POSITIVE, - strand2=BedStrand.NEGATIVE, + strand1=BedStrand.Positive, + strand2=BedStrand.Negative, ) expected: list[Bed6] = [ - Bed6(contig="chr1", start=1, end=2, name="foo", score=5, strand=BedStrand.POSITIVE), - Bed6(contig="chr2", start=3, end=4, name="foo", score=5, strand=BedStrand.NEGATIVE), + Bed6(contig="chr1", start=1, end=2, name="foo", score=5, strand=BedStrand.Positive), + Bed6(contig="chr2", start=3, end=4, name="foo", score=5, strand=BedStrand.Negative), ] assert list(record.territory()) == expected def test_that_decoding_splits_on_any_whitespace() -> None: - """Test that we can decode a BED on arbitrary whitespace.""" + """Test that we can decode a BED on arbitrary whitespace (tabs or spaces).""" assert Bed3.decode(" chr1 \t 1\t \t2 \n") == Bed3(contig="chr1", start=1, end=2) -def test_all_bed_types_have_fieldnames() -> None: +def test_that_we_can_decode_all_bed_types_from_strings() -> None: + """Test that we can decode all builtin BED types from strings.""" # fmt: off assert Bed2.decode("chr1\t1") == Bed2(contig="chr1", start=1) assert Bed3.decode("chr1\t1\t2") == Bed3(contig="chr1", start=1, end=2) assert Bed4.decode("chr1\t1\t2\tfoo") == Bed4(contig="chr1", start=1, end=2, name="foo") assert Bed5.decode("chr1\t1\t2\tfoo\t3") == Bed5(contig="chr1", start=1, end=2, name="foo", score=3) # noqa: E501 - assert Bed6.decode("chr1\t1\t2\tfoo\t3\t+") == Bed6(contig="chr1", start=1, end=2, name="foo", score=3, strand=BedStrand.POSITIVE) # noqa: E501 + assert Bed6.decode("chr1\t1\t2\tfoo\t3\t+") == Bed6(contig="chr1", start=1, end=2, name="foo", score=3, strand=BedStrand.Positive) # noqa: E501 + assert BedGraph.decode("chr1\t1\t2\t0.2") == BedGraph(contig="chr1", start=1, end=2, value=0.2) assert BedPE.decode("chr1\t1\t2\tchr2\t3\t4\tfoo\t5\t+\t-") == BedPE( contig1="chr1", start1=1, @@ -222,8 +157,8 @@ def test_all_bed_types_have_fieldnames() -> None: end2=4, name="foo", score=5, - strand1=BedStrand.POSITIVE, - strand2=BedStrand.NEGATIVE, + strand1=BedStrand.Positive, + strand2=BedStrand.Negative, ) # fmt: on @@ -288,6 +223,7 @@ class PairedBed6_1(PairBed): end2=4, custom1=4.0, ) + assert PairedBed6_1.decode("chr1\t1\t2\tchr2\t3\t4\t4.0") == decoded territory = list(decoded.territory()) assert territory == [Bed3(contig="chr1", start=1, end=2), Bed3(contig="chr2", start=3, end=4)] @@ -303,7 +239,9 @@ class Bed2_1(PointBed): start: int custom1: float - with pytest.raises(TypeError, match="You must mark custom BED records with @dataclass."): + with pytest.raises( + TypeError, match="You must annotate custom BED class definitions with @dataclass!" + ): Bed2_1(contig="chr1", start=1, custom1=3.0) # type: ignore[abstract] @@ -328,269 +266,3 @@ def test_that_we_get_a_helpful_error_when_we_cant_decode_the_types() -> None: ), ): Bed2.decode("chr1\tchr1") - - -@pytest.mark.parametrize( - "bed,expected", - [ - [Bed2(contig="chr1", start=1), "chr1\t1\n"], - [Bed3(contig="chr1", start=1, end=2), "chr1\t1\t2\n"], - [Bed4(contig="chr1", start=1, end=2, name="foo"), "chr1\t1\t2\tfoo\n"], - [Bed5(contig="chr1", start=1, end=2, name="foo", score=3), "chr1\t1\t2\tfoo\t3\n"], - [ - Bed6(contig="chr1", start=1, end=2, name="foo", score=3, strand=BedStrand.POSITIVE), - "chr1\t1\t2\tfoo\t3\t+\n", - ], # fmt: skip - [ - BedPE( - contig1="chr1", - start1=1, - end1=2, - contig2="chr2", - start2=3, - end2=4, - name="foo", - score=5, - strand1=BedStrand.POSITIVE, - strand2=BedStrand.NEGATIVE, - ), - "chr1\t1\t2\tchr2\t3\t4\tfoo\t5\t+\t-\n", - ], - ], -) -def test_bed_writer_can_write_all_bed_types(bed: BedType, expected: str, tmp_path: Path) -> None: - """Test that the BED writer can write all BED types.""" - with open(tmp_path / "test.bed", "w") as handle: - writer: BedWriter = BedWriter(handle) - writer.write(bed) - - assert Path(tmp_path / "test.bed").read_text() == expected - -def test_bed_writer_can_be_closed(tmp_path: Path) -> None: - """Test that we can close a BED writer.""" - path: Path = tmp_path / "test.bed" - writer = BedWriter[Bed3](open(path, "w")) - writer.write(Bed3(contig="chr1", start=1, end=2)) - writer.close() - - with pytest.raises(ValueError, match="I/O operation on closed file"): - writer.write(Bed3(contig="chr1", start=1, end=2)) - - -def test_bed_wrtier_can_write_bed_records_from_a_path(tmp_path: Path) -> None: - """Test that the BED write can write BED records from a path if it is typed.""" - - bed: Bed3 = Bed3(contig="chr1", start=1, end=2) - - with BedWriter[Bed3].from_path(tmp_path / "test1.bed") as writer: - writer.write(bed) - - assert (tmp_path / "test1.bed").read_text() == "chr1\t1\t2\n" - - with BedWriter[Bed3].from_path(str(tmp_path / "test2.bed")) as writer: - writer.write(bed) - - assert (tmp_path / "test2.bed").read_text() == "chr1\t1\t2\n" - - -def test_bed_writer_can_write_all_at_once(tmp_path: Path) -> None: - """Test that the BED writer can write multiple BED records at once.""" - expected: str = "chr1\t1\t2\nchr2\t3\t4\n" - - def records() -> Iterator[Bed3]: - yield Bed3(contig="chr1", start=1, end=2) - yield Bed3(contig="chr2", start=3, end=4) - - with open(tmp_path / "test.bed", "w") as handle: - BedWriter[Bed3](handle).write_all(records()) - - assert Path(tmp_path / "test.bed").read_text() == expected - - -def test_bed_writer_remembers_the_type_it_will_write(tmp_path: Path) -> None: - """Test that the BED writer remembers the type it can only write.""" - with open(tmp_path / "test.bed", "w") as handle: - writer: BedWriter = BedWriter(handle) - writer.write(Bed2(contig="chr1", start=1)) - assert writer.bed_kind is Bed2 - with pytest.raises( - TypeError, - match=( - "BedWriter can only continue to write features of the same type. Will not write a" - " Bed3 after a Bed2" - ), - ): - writer.write(Bed3(contig="chr1", start=1, end=2)) - - -def test_bed_writer_remembers_the_type_it_will_write_generic(tmp_path: Path) -> None: - """Test that the generically parameterized BED writer remembers the type it can only write.""" - with open(tmp_path / "test.bed", "w") as handle: - writer = BedWriter[Bed2](handle) - assert writer.bed_kind is Bed2 - with pytest.raises( - TypeError, - match=( - "BedWriter can only continue to write features of the same type. Will not write a" - " Bed3 after a Bed2" - ), - ): - writer.write(Bed3(contig="chr1", start=1, end=2)) # type: ignore[arg-type] - - -def test_bed_writer_write_comment_with_prefix_pound_symbol(tmp_path: Path) -> None: - """Test that we can write comments that have a leading pound symbol.""" - with open(tmp_path / "test.bed", "w") as handle: - writer = BedWriter[Bed2](handle) - writer.write_comment("# hello mom!") - writer.write(Bed2(contig="chr1", start=1)) - writer.write_comment("# hello dad!") - writer.write(Bed2(contig="chr2", start=2)) - - expected = "# hello mom!\nchr1\t1\n# hello dad!\nchr2\t2\n" - assert Path(tmp_path / "test.bed").read_text() == expected - - -def test_bed_writer_write_comment_without_prefix_pound_symbol(tmp_path: Path) -> None: - """Test that we can write comments that do not have a leading pound symbol.""" - with open(tmp_path / "test.bed", "w") as handle: - writer = BedWriter[Bed2](handle) - writer.write_comment("track this-is-fine") - writer.write_comment("browser is mario's enemy?") - writer.write_comment("hello mom!") - writer.write(Bed2(contig="chr1", start=1)) - writer.write_comment("hello dad!") - writer.write(Bed2(contig="chr2", start=2)) - - expected = ( - "track this-is-fine\n" - "browser is mario's enemy?\n" - "# hello mom!\n" - "chr1\t1\n" - "# hello dad!\n" - "chr2\t2\n" - ) - - assert Path(tmp_path / "test.bed").read_text() == expected - -def test_bed_writer_can_be_used_as_context_manager(tmp_path: Path) -> None: - """Test that the BED writer can be used as a context manager.""" - with BedWriter[Bed2](open(tmp_path / "test.bed", "w")) as handle: - handle.write(Bed2(contig="chr1", start=1)) - handle.write(Bed2(contig="chr2", start=2)) - - expected = "chr1\t1\nchr2\t2\n" - assert Path(tmp_path / "test.bed").read_text() == expected - -def test_bed_reader_can_read_bed_records_if_typed(tmp_path: Path) -> None: - """Test that the BED reader can read BED records if the reader is typed.""" - - bed: Bed3 = Bed3(contig="chr1", start=1, end=2) - - with open(tmp_path / "test.bed", "w") as handle: - writer: BedWriter = BedWriter(handle) - writer.write(bed) - - assert Path(tmp_path / "test.bed").read_text() == "chr1\t1\t2\n" - - with open(tmp_path / "test.bed", "r") as handle: - assert list(BedReader[Bed3](handle)) == [bed] - - -def test_bed_reader_can_be_closed(tmp_path: Path) -> None: - """Test that we can close a BED reader.""" - path: Path = tmp_path / "test.bed" - path.touch() - reader = BedReader[Bed3](open(path)) - reader.close() - - with pytest.raises(ValueError, match="I/O operation on closed file"): - next(iter(reader)) - - -def test_bed_reader_can_read_bed_records_from_a_path(tmp_path: Path) -> None: - """Test that the BED reader can read BED records from a path if it is typed.""" - - bed: Bed3 = Bed3(contig="chr1", start=1, end=2) - - with open(tmp_path / "test.bed", "w") as handle: - writer: BedWriter = BedWriter(handle) - writer.write(bed) - - assert Path(tmp_path / "test.bed").read_text() == "chr1\t1\t2\n" - - reader = BedReader[Bed3].from_path(tmp_path / "test.bed") - assert list(reader) == [bed] - - reader = BedReader[Bed3].from_path(str(tmp_path / "test.bed")) - assert list(reader) == [bed] - - -def test_bed_reader_can_raises_exception_if_not_typed(tmp_path: Path) -> None: - """Test that the BED reader raises an exception if it is not typed.""" - - bed: Bed3 = Bed3(contig="chr1", start=1, end=2) - - with open(tmp_path / "test.bed", "w") as handle: - writer: BedWriter = BedWriter(handle) - writer.write(bed) - - assert Path(tmp_path / "test.bed").read_text() == "chr1\t1\t2\n" - - with open(tmp_path / "test.bed", "r") as handle: - with pytest.raises( - NotImplementedError, - match="Untyped reading is not yet supported!", - ): - list(BedReader(handle)) - - -def test_bed_reader_can_read_bed_records_with_comments(tmp_path: Path) -> None: - """Test that the BED reader can read BED records with comments.""" - - bed: Bed3 = Bed3(contig="chr1", start=1, end=2) - - with open(tmp_path / "test.bed", "w") as handle: - writer: BedWriter = BedWriter(handle) - writer.write_comment("track this-is-fine") - writer.write_comment("browser is mario's enemy?") - writer.write_comment("hello mom!") - handle.write("\n") # empty line - handle.write(" \t\n") # empty line - writer.write(bed) - writer.write_comment("hello dad!") - - with open(tmp_path / "test.bed", "r") as handle: - assert list(BedReader[Bed3](handle)) == [bed] - - -def test_bed_reader_can_read_optional_string_types(tmp_path: Path) -> None: - """Test that the BED reader can read BED records with optional string types.""" - - bed: Bed4 = Bed4(contig="chr1", start=1, end=2, name=None) - - (tmp_path / "test.bed").write_text(f"chr1\t1\t2\t{MISSING_FIELD}\n") - - with open(tmp_path / "test.bed", "r") as handle: - assert list(BedReader[Bed4](handle)) == [bed] - - -def test_bed_reader_can_read_optional_other_types(tmp_path: Path) -> None: - """Test that the BED reader can read BED records with optional other types.""" - - bed: Bed5 = Bed5(contig="chr1", start=1, end=2, name="foo", score=None) - - (tmp_path / "test.bed").write_text(f"chr1\t1\t2\tfoo\t{MISSING_FIELD}\n") - - with open(tmp_path / "test.bed", "r") as handle: - assert list(BedReader[Bed5](handle)) == [bed] - - -def test_bed_reader_can_be_used_as_context_manager(tmp_path: Path) -> None: - """Test that the BED reader can be used as a context manager.""" - bed: Bed4 = Bed4(contig="chr1", start=1, end=2, name=None) - - (tmp_path / "test.bed").write_text(f"chr1\t1\t2\t{MISSING_FIELD}\n") - - with BedReader[Bed4](open(tmp_path / "test.bed")) as reader: - assert list(reader) == [bed] diff --git a/tests/test_io.py b/tests/test_io.py new file mode 100644 index 0000000..5e25b27 --- /dev/null +++ b/tests/test_io.py @@ -0,0 +1,286 @@ +from pathlib import Path +from typing import Iterator + +import pytest + +from bedspec import Bed2 +from bedspec import Bed3 +from bedspec import Bed4 +from bedspec import Bed5 +from bedspec import Bed6 +from bedspec import BedGraph +from bedspec import BedPE +from bedspec import BedReader +from bedspec import BedStrand +from bedspec import BedType +from bedspec import BedWriter +from bedspec._io import MISSING_FIELD + + +# fmt: off +@pytest.mark.parametrize( + "bed,expected", + [ + [Bed2(contig="chr1", start=1), "chr1\t1\n"], + [Bed3(contig="chr1", start=1, end=2), "chr1\t1\t2\n"], + [Bed4(contig="chr1", start=1, end=2, name="foo"), "chr1\t1\t2\tfoo\n"], + [Bed5(contig="chr1", start=1, end=2, name="foo", score=3), "chr1\t1\t2\tfoo\t3\n"], + [Bed6(contig="chr1", start=1, end=2, name="foo", score=3, strand=BedStrand.Positive), "chr1\t1\t2\tfoo\t3\t+\n"], # noqa: E501 + [BedGraph(contig="chr1", start=1, end=2, value=0.2), "chr1\t1\t2\t0.2\n"], + [ + BedPE( + contig1="chr1", + start1=1, + end1=2, + contig2="chr2", + start2=3, + end2=4, + name="foo", + score=5, + strand1=BedStrand.Positive, + strand2=BedStrand.Negative, + ), + "chr1\t1\t2\tchr2\t3\t4\tfoo\t5\t+\t-\n", + ], + ], +) +# fmt: on +def test_bed_writer_can_write_all_bed_types(bed: BedType, expected: str, tmp_path: Path) -> None: + """Test that the BED writer can write all BED types.""" + with open(tmp_path / "test.bed", "w") as handle: + writer: BedWriter = BedWriter(handle) + writer.write(bed) + + assert Path(tmp_path / "test.bed").read_text() == expected + + +def test_bed_writer_can_be_closed(tmp_path: Path) -> None: + """Test that we can close a BED writer.""" + path: Path = tmp_path / "test.bed" + writer = BedWriter[Bed3](open(path, "w")) + writer.write(Bed3(contig="chr1", start=1, end=2)) + writer.close() + + with pytest.raises(ValueError, match="I/O operation on closed file"): + writer.write(Bed3(contig="chr1", start=1, end=2)) + + +def test_bed_writer_can_write_bed_records_from_a_path(tmp_path: Path) -> None: + """Test that the BED write can write BED records from a path if it is typed.""" + + bed: Bed3 = Bed3(contig="chr1", start=1, end=2) + + with BedWriter[Bed3].from_path(tmp_path / "test1.bed") as writer: + writer.write(bed) + + assert (tmp_path / "test1.bed").read_text() == "chr1\t1\t2\n" + + with BedWriter[Bed3].from_path(str(tmp_path / "test2.bed")) as writer: + writer.write(bed) + + assert (tmp_path / "test2.bed").read_text() == "chr1\t1\t2\n" + + +def test_bed_writer_can_write_all_at_once(tmp_path: Path) -> None: + """Test that the BED writer can write multiple BED records at once.""" + expected: str = "chr1\t1\t2\nchr2\t3\t4\n" + + def records() -> Iterator[Bed3]: + yield Bed3(contig="chr1", start=1, end=2) + yield Bed3(contig="chr2", start=3, end=4) + + with open(tmp_path / "test.bed", "w") as handle: + BedWriter[Bed3](handle).write_all(records()) + + assert Path(tmp_path / "test.bed").read_text() == expected + + +def test_bed_writer_remembers_the_type_it_will_write(tmp_path: Path) -> None: + """Test that the BED writer remembers the type it can only write.""" + with open(tmp_path / "test.bed", "w") as handle: + writer: BedWriter = BedWriter(handle) + writer.write(Bed2(contig="chr1", start=1)) + assert writer.bed_kind is Bed2 + with pytest.raises( + TypeError, + match=( + "BedWriter can only continue to write features of the same type. Will not write a" + " Bed3 after a Bed2" + ), + ): + writer.write(Bed3(contig="chr1", start=1, end=2)) + + +def test_bed_writer_remembers_the_type_it_will_write_generic(tmp_path: Path) -> None: + """Test that the generically parameterized BED writer remembers the type it can only write.""" + with open(tmp_path / "test.bed", "w") as handle: + writer = BedWriter[Bed2](handle) + assert writer.bed_kind is Bed2 + with pytest.raises( + TypeError, + match=( + "BedWriter can only continue to write features of the same type. Will not write a" + " Bed3 after a Bed2" + ), + ): + writer.write(Bed3(contig="chr1", start=1, end=2)) # type: ignore[arg-type] + + +def test_bed_writer_write_comment_with_prefix_pound_symbol(tmp_path: Path) -> None: + """Test that we can write comments that have a leading pound symbol.""" + with open(tmp_path / "test.bed", "w") as handle: + writer = BedWriter[Bed2](handle) + writer.write_comment("# hello mom!") + writer.write(Bed2(contig="chr1", start=1)) + writer.write_comment("# hello dad!") + writer.write(Bed2(contig="chr2", start=2)) + + expected = "# hello mom!\nchr1\t1\n# hello dad!\nchr2\t2\n" + assert Path(tmp_path / "test.bed").read_text() == expected + + +def test_bed_writer_write_comment_without_prefix_pound_symbol(tmp_path: Path) -> None: + """Test that we can write comments that do not have a leading pound symbol.""" + with open(tmp_path / "test.bed", "w") as handle: + writer = BedWriter[Bed2](handle) + writer.write_comment("track this-is-fine") + writer.write_comment("browser is mario's enemy?") + writer.write_comment("hello mom!") + writer.write(Bed2(contig="chr1", start=1)) + writer.write_comment("hello dad!") + writer.write(Bed2(contig="chr2", start=2)) + + expected = ( + "track this-is-fine\n" + "browser is mario's enemy?\n" + "# hello mom!\n" + "chr1\t1\n" + "# hello dad!\n" + "chr2\t2\n" + ) + + assert Path(tmp_path / "test.bed").read_text() == expected + + +def test_bed_writer_can_be_used_as_context_manager(tmp_path: Path) -> None: + """Test that the BED writer can be used as a context manager.""" + with BedWriter[Bed2](open(tmp_path / "test.bed", "w")) as handle: + handle.write(Bed2(contig="chr1", start=1)) + handle.write(Bed2(contig="chr2", start=2)) + + expected = "chr1\t1\nchr2\t2\n" + assert Path(tmp_path / "test.bed").read_text() == expected + + +def test_bed_reader_can_read_bed_records_if_typed(tmp_path: Path) -> None: + """Test that the BED reader can read BED records if the reader is typed.""" + + bed: Bed3 = Bed3(contig="chr1", start=1, end=2) + + with open(tmp_path / "test.bed", "w") as handle: + writer: BedWriter = BedWriter(handle) + writer.write(bed) + + assert Path(tmp_path / "test.bed").read_text() == "chr1\t1\t2\n" + + with open(tmp_path / "test.bed", "r") as handle: + assert list(BedReader[Bed3](handle)) == [bed] + + +def test_bed_reader_can_be_closed(tmp_path: Path) -> None: + """Test that we can close a BED reader.""" + path: Path = tmp_path / "test.bed" + path.touch() + reader = BedReader[Bed3](open(path)) + reader.close() + + with pytest.raises(ValueError, match="I/O operation on closed file"): + next(iter(reader)) + + +def test_bed_reader_can_read_bed_records_from_a_path(tmp_path: Path) -> None: + """Test that the BED reader can read BED records from a path if it is typed.""" + + bed: Bed3 = Bed3(contig="chr1", start=1, end=2) + + with open(tmp_path / "test.bed", "w") as handle: + writer: BedWriter = BedWriter(handle) + writer.write(bed) + + assert Path(tmp_path / "test.bed").read_text() == "chr1\t1\t2\n" + + reader = BedReader[Bed3].from_path(tmp_path / "test.bed") + assert list(reader) == [bed] + + reader = BedReader[Bed3].from_path(str(tmp_path / "test.bed")) + assert list(reader) == [bed] + + +def test_bed_reader_can_raises_exception_if_not_typed(tmp_path: Path) -> None: + """Test that the BED reader raises an exception if it is not typed.""" + + bed: Bed3 = Bed3(contig="chr1", start=1, end=2) + + with open(tmp_path / "test.bed", "w") as handle: + writer: BedWriter = BedWriter(handle) + writer.write(bed) + + assert Path(tmp_path / "test.bed").read_text() == "chr1\t1\t2\n" + + with open(tmp_path / "test.bed", "r") as handle: + with pytest.raises( + NotImplementedError, + match="Untyped reading is not yet supported!", + ): + list(BedReader(handle)) + + +def test_bed_reader_can_read_bed_records_with_comments(tmp_path: Path) -> None: + """Test that the BED reader can read BED records with comments.""" + + bed: Bed3 = Bed3(contig="chr1", start=1, end=2) + + with open(tmp_path / "test.bed", "w") as handle: + writer: BedWriter = BedWriter(handle) + writer.write_comment("track this-is-fine") + writer.write_comment("browser is mario's enemy?") + writer.write_comment("hello mom!") + handle.write("\n") # empty line + handle.write(" \t\n") # empty line + writer.write(bed) + writer.write_comment("hello dad!") + + with open(tmp_path / "test.bed", "r") as handle: + assert list(BedReader[Bed3](handle)) == [bed] + + +def test_bed_reader_can_read_optional_string_types(tmp_path: Path) -> None: + """Test that the BED reader can read BED records with optional string types.""" + + bed: Bed4 = Bed4(contig="chr1", start=1, end=2, name=None) + + (tmp_path / "test.bed").write_text(f"chr1\t1\t2\t{MISSING_FIELD}\n") + + with open(tmp_path / "test.bed", "r") as handle: + assert list(BedReader[Bed4](handle)) == [bed] + + +def test_bed_reader_can_read_optional_other_types(tmp_path: Path) -> None: + """Test that the BED reader can read BED records with optional other types.""" + + bed: Bed5 = Bed5(contig="chr1", start=1, end=2, name="foo", score=None) + + (tmp_path / "test.bed").write_text(f"chr1\t1\t2\tfoo\t{MISSING_FIELD}\n") + + with open(tmp_path / "test.bed", "r") as handle: + assert list(BedReader[Bed5](handle)) == [bed] + + +def test_bed_reader_can_be_used_as_context_manager(tmp_path: Path) -> None: + """Test that the BED reader can be used as a context manager.""" + bed: Bed4 = Bed4(contig="chr1", start=1, end=2, name=None) + + (tmp_path / "test.bed").write_text(f"chr1\t1\t2\t{MISSING_FIELD}\n") + + with BedReader[Bed4](open(tmp_path / "test.bed")) as reader: + assert list(reader) == [bed] diff --git a/tests/test_overlap.py b/tests/test_overlap.py new file mode 100644 index 0000000..8f6f7c5 --- /dev/null +++ b/tests/test_overlap.py @@ -0,0 +1,115 @@ +from dataclasses import dataclass +from typing import TypeAlias + +from bedspec import Bed3 +from bedspec import Bed4 +from bedspec.overlap import OverlapDetector + + +def test_overlap_detector_as_iterable() -> None: + """Test we can iterate over all the intervals we put into the overlap detector.""" + bed1 = Bed3(contig="chr1", start=1, end=2) + bed2 = Bed3(contig="chr2", start=4, end=5) + detector: OverlapDetector[Bed3] = OverlapDetector([bed1, bed2]) + assert list(detector) == [bed1, bed2] + + +def test_we_can_mix_types_in_the_overlap_detector() -> None: + """Test mix input types when building the overlap detector.""" + bed1 = Bed3(contig="chr1", start=1, end=2) + bed2 = Bed4(contig="chr2", start=4, end=5, name="Clint Valentine") + detector: OverlapDetector[Bed3 | Bed4] = OverlapDetector([bed1, bed2]) + assert list(detector) == [bed1, bed2] + + +def test_we_can_add_a_feature_to_the_overlap_detector() -> None: + """Test we can add a feature to the overlap detector.""" + bed1 = Bed3(contig="chr1", start=1, end=2) + bed2 = Bed4(contig="chr2", start=4, end=5, name="Clint Valentine") + detector: OverlapDetector[Bed3 | Bed4] = OverlapDetector() + detector.add(bed1) + detector.add(bed2) + assert list(detector) == [bed1, bed2] + + +def test_we_can_add_all_features_to_the_overlap_detector() -> None: + """Test we can add all features to the overlap detector.""" + bed1 = Bed3(contig="chr1", start=1, end=2) + bed2 = Bed4(contig="chr2", start=4, end=5, name="Clint Valentine") + detector: OverlapDetector[Bed3 | Bed4] = OverlapDetector() + detector.add_all([bed1, bed2]) + assert list(detector) == [bed1, bed2] + + +def test_we_can_query_with_different_type_in_the_overlap_detector() -> None: + """Test we can query with a different type in the overlap detector.""" + bed1 = Bed3(contig="chr1", start=1, end=2) + bed2 = Bed4(contig="chr1", start=1, end=2, name="Clint Valentine") + detector: OverlapDetector[Bed3] = OverlapDetector([bed1]) + assert list(detector.get_overlapping(bed2)) == [bed1] + + +def test_we_can_query_for_overlapping_features() -> None: + """Test we can query for features that overlap using the overlap detector.""" + bed1 = Bed3(contig="chr1", start=2, end=5) + bed2 = Bed3(contig="chr1", start=4, end=10) + bed3 = Bed3(contig="chr2", start=4, end=5) + detector: OverlapDetector[Bed3] = OverlapDetector([bed1, bed2, bed3]) + + assert list(detector) == [bed1, bed2, bed3] + + assert list(detector.get_overlapping(Bed3("chr1", 0, 1))) == [] + assert list(detector.get_overlapping(Bed3("chr1", 2, 3))) == [bed1] + assert list(detector.get_overlapping(Bed3("chr1", 4, 5))) == [bed1, bed2] + assert list(detector.get_overlapping(Bed3("chr1", 5, 6))) == [bed2] + assert list(detector.get_overlapping(Bed3("chr2", 0, 1))) == [] + assert list(detector.get_overlapping(Bed3("chr2", 4, 5))) == [bed3] + + +def test_we_can_query_if_at_least_one_feature_overlaps() -> None: + """Test we can query if at least one feature overlaps using the overlap detector.""" + bed1 = Bed3(contig="chr1", start=2, end=5) + bed2 = Bed3(contig="chr1", start=4, end=10) + bed3 = Bed3(contig="chr2", start=4, end=5) + detector: OverlapDetector[Bed3] = OverlapDetector([bed1, bed2, bed3]) + + assert list(detector) == [bed1, bed2, bed3] + + assert not detector.overlaps_any(Bed3("chr1", 0, 1)) + assert detector.overlaps_any(Bed3("chr1", 2, 3)) + assert detector.overlaps_any(Bed3("chr1", 4, 5)) + assert detector.overlaps_any(Bed3("chr1", 5, 6)) + assert not detector.overlaps_any(Bed3("chr2", 0, 1)) + assert detector.overlaps_any(Bed3("chr2", 4, 5)) + + +def test_we_support_features_with_all_three_common_reference_sequence_name_properties() -> None: + """Test that we can store features with either of 3 reference sequence name properties.""" + + @dataclass(eq=True, frozen=True) + class FeatureWithChrom: + chrom: str + start: int + end: int + + @dataclass(eq=True, frozen=True) + class FeatureWithContig: + contig: str + start: int + end: int + + @dataclass(eq=True, frozen=True) + class FeatureWithRefname: + refname: str + start: int + end: int + + feature_with_chrom: FeatureWithChrom = FeatureWithChrom("chr1", 1, 3) + feature_with_contig: FeatureWithContig = FeatureWithContig("chr1", 1, 3) + feature_with_refname: FeatureWithRefname = FeatureWithRefname("chr1", 1, 3) + + AllKinds: TypeAlias = FeatureWithChrom | FeatureWithContig | FeatureWithRefname + features: list[AllKinds] = [feature_with_chrom, feature_with_contig, feature_with_refname] + detector: OverlapDetector[AllKinds] = OverlapDetector(features) + + assert list(detector) == features diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..e323add --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,73 @@ +import dataclasses + +import pytest + +import bedspec +from bedspec import Bed2 +from bedspec import Bed3 +from bedspec import Bed4 +from bedspec import Bed5 +from bedspec import Bed6 +from bedspec import BedColor +from bedspec import BedGraph +from bedspec import BedPE +from bedspec import BedStrand +from bedspec import BedType +from bedspec import GenomicSpan +from bedspec import PairBed +from bedspec import PointBed +from bedspec import SimpleBed +from bedspec import Stranded + + +def test_bed_strand() -> None: + """Test that BED strands behave as string.""" + assert BedStrand("+") == BedStrand.Positive + assert BedStrand("-") == BedStrand.Negative + assert str(BedStrand.Positive) == "+" + assert str(BedStrand.Negative) == "-" + + +def test_bed_strand_opposite() -> None: + """Test that we return an opposite BED strand.""" + assert BedStrand.Positive.opposite() == BedStrand.Negative + assert BedStrand.Negative.opposite() == BedStrand.Positive + + +def test_bed_color() -> None: + """Test the small helper class for BED color.""" + assert str(BedColor(2, 3, 4)) == "2,3,4" + + +@pytest.mark.parametrize("bed_type", (PointBed, SimpleBed, PairBed)) +def test_bed_type_class_hierarchy(bed_type: type[BedType]) -> None: + """Test that all abstract base classes are subclasses of BedType.""" + assert issubclass(bed_type, BedType) + + +@pytest.mark.parametrize("bed_type", (Bed2, Bed3, Bed4, Bed5, Bed6, BedGraph, BedPE)) +def test_all_bed_types_are_dataclasses(bed_type: type[BedType]) -> None: + """Test that a simple BED record behaves as expected.""" + assert dataclasses.is_dataclass(bed_type) + + +def test_locatable_structural_type() -> None: + """Test that the GenomicSpan structural type is set correctly.""" + span: GenomicSpan = Bed6( + contig="chr1", start=1, end=2, name="foo", score=3, strand=BedStrand.Positive + ) + assert isinstance(span, GenomicSpan) + + +def test_stranded_structural_type() -> None: + """Test that the Stranded structural type is set correctly.""" + stranded: Stranded = Bed6( + contig="chr1", start=1, end=2, name="foo", score=3, strand=BedStrand.Positive + ) + assert isinstance(stranded, Stranded) + + +def test_dataclass_protocol_structural_type() -> None: + """Test that the dataclass structural type is set correctly.""" + bed: bedspec._types.DataclassInstance = Bed2(contig="chr1", start=1) + assert isinstance(bed, bedspec._types.DataclassInstance) diff --git a/tests/test_typing.py b/tests/test_typing.py new file mode 100644 index 0000000..7fff591 --- /dev/null +++ b/tests/test_typing.py @@ -0,0 +1,8 @@ +from bedspec._typing import is_union + + +def test_is_union() -> None: + """Test that a union type is the conjunction of two types.""" + # TODO: have a positive unit test for is_union + assert not is_union(type(int)) + assert not is_union(type(None))