Repository navigation
feat: persist variant genomes with a deterministic ID #255
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
81bfeaf
e79ca86
9aebf56
65ffba8
c9f4778
9fad81b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,21 @@ | ||
| # Copyright (C) 2016-2023 Deep Genomics Inc. All Rights Reserved. | ||
| from __future__ import absolute_import | ||
|
|
||
| import gzip | ||
| import hashlib | ||
| import os | ||
| import pickle | ||
| import re | ||
| import tempfile | ||
| from functools import partial | ||
| from operator import attrgetter | ||
|
|
||
| from . import _util | ||
| from . import gk_data | ||
| from . import interval as _interval | ||
| from ._apply_variants import apply_variants, check_variants_list | ||
| from .genome import Genome | ||
| from .variant import Variant | ||
|
|
||
|
|
||
| class VariantGenome(object): | ||
|
|
@@ -21,10 +29,22 @@ class VariantGenome(object): | |
| will be aligned to the variant genome's coordinate system. | ||
|
|
||
| TODO: see explanation of anchors. | ||
|
|
||
| A variant genome can be persisted and recreated later, in another process | ||
| or on another machine, from its content-derived :py:attr:`id`:: | ||
|
|
||
| >>> variant_genome = VariantGenome(genome, variants) | ||
| >>> variant_genome_id = variant_genome.save() # keep this id, e.g. in a database | ||
| >>> VariantGenome.load(variant_genome_id) == variant_genome | ||
| True | ||
|
|
||
| Saving requires a writable data manager; see :py:meth:`save`. | ||
| """ | ||
|
|
||
| __slots__ = ('genome', 'variants') | ||
|
|
||
| _FILENAME_FORMAT = "variant_genome.{}.txt.gz" | ||
|
|
||
| def __init__(self, reference_genome, variants): | ||
| """Initialize a variant genome. | ||
|
|
||
|
|
@@ -204,20 +224,83 @@ def __repr__(self): | |
| def reference_genome(self): | ||
| return self.genome.reference_genome | ||
|
|
||
| def _canonical_variants(self) -> list[Variant]: | ||
| # Normalized variants in a deterministic order, shared by `__eq__` and `id`. | ||
| return sorted((vv for v in self.variants for vv in v._normalized_variant), | ||
| key=attrgetter("chromosome", "start", "ref", "alt")) | ||
|
|
||
| @property | ||
| def id(self) -> str: | ||
| """A content-derived identifier for this variant genome (hex SHA-256 digest of the genome config and the normalized variants). | ||
|
|
||
| Two variant genomes have the same id if and only if they compare equal, | ||
| i.e. they have the same genome config and the same variants after normalization. | ||
| """ | ||
| hasher = hashlib.sha256(self.genome.config.encode()) | ||
| for variant in self._canonical_variants(): | ||
| hasher.update(b"\n" + variant.as_variant_string().encode()) | ||
| return hasher.hexdigest() | ||
|
|
||
| def save(self) -> str: | ||
| """Save this variant genome so that it can be recreated with :py:meth:`load`. | ||
| Returns the :py:attr:`id` to pass to :py:meth:`load`. | ||
|
|
||
| In the current implementation, a file is uploaded per-variant genome with ``genome_kit.gk_data.data_manager``. | ||
| Equal variant genomes share a single file. | ||
|
|
||
| The default open source data manager points at GenomeKit's public bucket, which is | ||
| read-only. To save, bring your own data manager (see :ref:`sharing-data`). | ||
| """ | ||
| variant_genome_id = self.id | ||
| filename = self._FILENAME_FORMAT.format(variant_genome_id) | ||
| with tempfile.TemporaryDirectory() as tmpdir: | ||
| path = os.path.join(tmpdir, filename) | ||
| lines = [self.genome.config] + [v.as_variant_string() for v in self._canonical_variants()] | ||
| # Fixed gzip header so equal genomes produce identical bytes | ||
| with open(path, "wb") as raw, gzip.GzipFile(fileobj=raw, mode="wb", filename="", mtime=0) as f: | ||
| f.write("".join(line + "\n" for line in lines).encode()) | ||
| gk_data.data_manager.upload_file(path, filename) | ||
| return variant_genome_id | ||
|
|
||
| @classmethod | ||
| def load(cls, variant_genome_id: str) -> "VariantGenome": | ||
| """Recreate a variant genome that was stored with :py:meth:`save`. | ||
| Returns a variant genome equal to the one that was saved. | ||
|
|
||
| In the current implementation, a file is fetched with ``genome_kit.gk_data.data_manager``. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| variant_genome_id : :py:class:`str` | ||
| The id returned by :py:meth:`save`. | ||
|
|
||
| Raises | ||
| ------ | ||
| :py:class:`~genome_kit.data_manager.GKDataFileNotFoundError` | ||
| No variant genome with this id was found. | ||
| :py:exc:`ValueError` | ||
| `variant_genome_id` is not an id produced by :py:meth:`save`, or the stored file does not match it. | ||
| """ | ||
| if not re.fullmatch("[0-9a-f]{64}", variant_genome_id): | ||
| raise ValueError("Invalid variant genome id: {!r}".format(variant_genome_id)) | ||
| path = gk_data.data_manager.get_file(cls._FILENAME_FORMAT.format(variant_genome_id)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Small suggestion: should Currently, if some one uses there calls: GK would try to fetch files like: And I'm not sure what the error would be! |
||
| with gzip.open(path, "rt") as f: | ||
| genome = Genome(f.readline().rstrip("\n")) | ||
| variants = [] | ||
| for line in f: | ||
| chrom, pos, ref, alt = line.rstrip("\n").split(":") | ||
| variants.append(Variant(chrom, int(pos) - 1, ref, alt, genome)) | ||
| variant_genome = cls(genome, variants) | ||
| if variant_genome.id != variant_genome_id: | ||
| raise ValueError("Stored variant genome does not match id {}".format(variant_genome_id)) | ||
| return variant_genome | ||
|
|
||
| def __eq__(self, other): | ||
| if not isinstance(other, VariantGenome): | ||
| return False | ||
| if self.genome != other.genome: | ||
| return False | ||
| # sort and normalize both sides. | ||
| # `start` is ok to use here because VariantGenome doesn't support overlapping variants in any case. | ||
| vself = sorted((vv for n in (v._normalized_variant for v in self.variants) for vv in n), | ||
| key=attrgetter('start')) | ||
| vother = sorted((vv for n in (v._normalized_variant for v in other.variants) for vv in n), | ||
| key=attrgetter('start')) | ||
| if vself != vother: | ||
| if self.genome.config != other.genome.config: | ||
| return False | ||
| return True | ||
| return self._canonical_variants() == other._canonical_variants() | ||
|
|
||
| def __getattr__(self, name): | ||
| # Intercept any attribute requests that weren't found on the VariantGenome object itself, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ Copyright (C) 2016-2023 Deep Genomics Inc. All Rights Reserved. | |
| #define __GK_ASSERT_H__ | ||
|
|
||
| #include "defines.h" | ||
| #include <exception> | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nice catch and sorry about that! |
||
| #include <format> | ||
| #include <stdexcept> | ||
| #include <string> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should this be
__hash__or it should be separate from the magic function?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Has to stay separate.
__hash__contract requires immutable state. It's safer to leave it intentionally un-hashable.