Skip to content

API Reference

Assets

Alignment asset types for Stargazer.

spec: docs/architecture/types.md

Alignment dataclass

Bases: Asset

BAM/CRAM alignment file asset.

Carries reference_cid and r1_cid for provenance (PROV entity derivation). The format is the file name's extension. Fields are kept to what fits a new public file's 9 metadata keys with the asset type and owner.

Source code in src/stargazer/assets/alignment.py
@dataclass
class Alignment(Asset):
    """BAM/CRAM alignment file asset.

    Carries reference_cid and r1_cid for provenance (PROV entity derivation).
    The format is the file name's extension. Fields are kept to what fits a
    new public file's 9 metadata keys with the asset type and owner.
    """

    _asset_key: ClassVar[str] = "alignment"
    sample_id: str = ""
    sorted: str = ""
    duplicates_marked: bool = False
    bqsr_applied: bool = False
    tool: str = ""
    reference_cid: str = ""
    r1_cid: str = ""

AlignmentIndex dataclass

Bases: Asset

BAI/CRAI alignment index file asset.

A companion: companion_of is the CID of the Alignment it indexes.

Source code in src/stargazer/assets/alignment.py
@dataclass
class AlignmentIndex(Asset):
    """BAI/CRAI alignment index file asset.

    A companion: `companion_of` is the CID of the Alignment it indexes.
    """

    _asset_key: ClassVar[str] = "alignment_index"
    sample_id: str = ""
    companion_of: str = ""

BQSRReport dataclass

Bases: Asset

BQSR recalibration table produced by GATK BaseRecalibrator.

Carries alignment_cid linking to the Alignment it was produced from.

Source code in src/stargazer/assets/alignment.py
@dataclass
class BQSRReport(Asset):
    """BQSR recalibration table produced by GATK BaseRecalibrator.

    Carries alignment_cid linking to the Alignment it was produced from.
    """

    _asset_key: ClassVar[str] = "bqsr_report"
    sample_id: str = ""
    tool: str = ""
    alignment_cid: str = ""

DuplicateMetrics dataclass

Bases: Asset

Duplicate metrics text file produced by GATK MarkDuplicates.

Carries alignment_cid linking to the Alignment it was produced from.

Source code in src/stargazer/assets/alignment.py
@dataclass
class DuplicateMetrics(Asset):
    """Duplicate metrics text file produced by GATK MarkDuplicates.

    Carries alignment_cid linking to the Alignment it was produced from.
    """

    _asset_key: ClassVar[str] = "duplicate_metrics"
    sample_id: str = ""
    tool: str = ""
    alignment_cid: str = ""

Asset base dataclass for Stargazer.

An Asset is a file plus typed metadata. cid is the file's IPFS CID, computed locally; path is a flyte.io.File naming where the stored bytes live (the object store, or an IPFS gateway for public data), so an asset passed between tasks always points somewhere every pod can read. fetch() makes a local copy, with its companions beside it, and returns its path. A companion is any asset whose companion_of keyvalue holds another asset's CID: an index or a sequence dictionary that tools expect beside the file it names.

spec: docs/architecture/types.md

Asset dataclass

Base class for all typed file assets in Stargazer.

Attributes:

Name Type Description
cid str

IPFS CID of the stored file

path File | None

The stored file as a flyte.io.File. Assigning a local Path or a URI string wraps it in a File, so an asset can be built from a local file that was never uploaded; fetch() then uses that file in place.

keyvalues dict[str, str]

On a bare Asset, all of its metadata, serialized verbatim (the asset key lives inside the dict): the catchall for kinds of file with no registered class in this process. On a typed subclass, the extra keys its class doesn't declare, kept as strings and written beside the declared fields.

Subclasses declare typed fields as normal dataclass attributes:

@dataclass
class Alignment(Asset):
    _asset_key: ClassVar[str] = "alignment"
    sample_id: str = ""
    duplicates_marked: bool = False

Fields are plain Python attributes. to_keyvalues() serializes them to dict[str, str] at storage boundaries; from_keyvalues() reconstructs from storage. str fields pass through directly; all other types use json.dumps / json.loads. declared_fields() lists a type's fields. Attributes are strict (assigning an undeclared one raises); extra keys go in keyvalues instead:

aln.keyvalues["lane"] = "3"
Source code in src/stargazer/assets/asset.py
@dataclass
class Asset:
    """Base class for all typed file assets in Stargazer.

    Attributes:
        cid: IPFS CID of the stored file
        path: The stored file as a `flyte.io.File`. Assigning a local `Path`
            or a URI string wraps it in a File, so an asset can be built from
            a local file that was never uploaded; `fetch()` then uses that
            file in place.
        keyvalues: On a bare Asset, all of its metadata, serialized
            verbatim (the ``asset`` key lives inside the dict): the catchall
            for kinds of file with no registered class in this process. On
            a typed subclass, the extra keys its class doesn't declare,
            kept as strings and written beside the declared fields.

    Subclasses declare typed fields as normal dataclass attributes:

        @dataclass
        class Alignment(Asset):
            _asset_key: ClassVar[str] = "alignment"
            sample_id: str = ""
            duplicates_marked: bool = False

    Fields are plain Python attributes. ``to_keyvalues()`` serializes them to
    ``dict[str, str]`` at storage boundaries; ``from_keyvalues()`` reconstructs
    from storage. ``str`` fields pass through directly; all other types use
    ``json.dumps`` / ``json.loads``. ``declared_fields()`` lists a type's
    fields. Attributes are strict (assigning an undeclared one raises); extra
    keys go in ``keyvalues`` instead:

        aln.keyvalues["lane"] = "3"
    """

    _registry: ClassVar[dict[str, type[Asset]]] = {}
    _asset_key: ClassVar[str] = ""

    cid: str = ""
    path: File | None = None
    keyvalues: dict[str, str] = field(default_factory=dict)

    def __init_subclass__(cls, **kwargs):
        """Register subclass in the asset registry."""
        super().__init_subclass__(**kwargs)
        ak = cls.__dict__.get("_asset_key", "")
        if ak:
            Asset._registry[ak] = cls

    def __setattr__(self, name: str, value: Any) -> None:
        """Enforce declared fields on typed subclasses and coerce `path` to a File."""
        if name == "path":
            value = _as_file(value)
        elif self._asset_key and not name.startswith("_") and name not in _BASE_FIELDS:
            allowed = self.declared_fields()
            if name not in allowed:
                raise AttributeError(
                    f"{type(self).__name__} has no field '{name}'. "
                    f"Allowed: {sorted(allowed)}"
                )
        super().__setattr__(name, value)

    @classmethod
    def declared_fields(cls) -> tuple[str, ...]:
        """Names of the fields this asset type declares, in declaration order.

        These are the typed keyvalues it reads and writes: every dataclass
        field except the base ``cid``, ``path`` and ``keyvalues``. Bare Asset
        declares none.
        """
        return tuple(
            f.name for f in dataclasses.fields(cls) if f.name not in _BASE_FIELDS
        )

    def to_keyvalues(self) -> dict[str, str]:
        """Serialize to storage format.

        str fields pass through as-is; all other types are serialized with
        json.dumps. A typed asset's extra keys are written beside its
        declared fields. Bare Asset instances return their keyvalues
        verbatim. The result is always a copy, so callers (e.g. ``_owner``
        stamping at the storage layer) can mutate it without touching the
        Asset.

        Raises:
            ValueError: when a typed asset's ``keyvalues`` holds ``asset``
                or one of its declared fields, which are set as attributes
        """
        if not self._asset_key:
            return dict(self.keyvalues)
        declared = self.declared_fields()
        clash = sorted(set(self.keyvalues) & {"asset", *declared})
        if clash:
            raise ValueError(
                f"{type(self).__name__}.keyvalues holds {clash}, which "
                f"{type(self).__name__} sets itself: assign the attribute instead"
            )
        hints = get_type_hints(type(self))
        result: dict[str, str] = {"asset": self._asset_key}
        for name in declared:
            val = getattr(self, name)
            result[name] = val if hints.get(name) is str else json.dumps(val)
        result.update(self.keyvalues)
        return result

    @classmethod
    def from_keyvalues(
        cls, kv: dict[str, str], cid: str = "", path: File | Path | str | None = None
    ) -> Asset:
        """Reconstruct from a storage keyvalues dict.

        str fields are assigned directly; all other types are deserialized
        with json.loads (raising ``json.JSONDecodeError`` on values that
        don't parse — callers that must tolerate malformed records catch it,
        see ``specialize()``). On a typed class, every key that isn't
        ``asset`` or a declared field becomes an extra in ``keyvalues``.
        Bare Asset receives the keyvalues verbatim.
        """
        if not cls._asset_key:
            return cls(cid=cid, path=path, keyvalues=dict(kv))
        hints = get_type_hints(cls)
        declared = cls.declared_fields()
        kwargs = {
            name: value if hints.get(name) is str else json.loads(value)
            for name, value in kv.items()
            if name in declared
        }
        extras = {k: v for k, v in kv.items() if k not in declared and k != "asset"}
        return cls(cid=cid, path=path, keyvalues=extras, **kwargs)

    def to_dict(self) -> dict:
        """Serialize to a JSON-friendly dict: the file's location and name."""
        return {
            "cid": self.cid,
            "path": self.path.path if self.path else None,
            "name": self.path.name if self.path else None,
            "keyvalues": self.to_keyvalues(),
        }

    @classmethod
    def from_dict(cls, data: dict) -> Self:
        """Reconstruct from a serialized dict."""
        path = None
        if data.get("path"):
            path = _as_file(data["path"])
            if data.get("name"):
                path = File(path=path.path, name=data["name"])
        return cls.from_keyvalues(
            data.get("keyvalues", {}), cid=data.get("cid", ""), path=path
        )

    async def fetch(self) -> Path:
        """Make a local copy of this asset and its companions; return its path.

        The asset lands at `<STARGAZER_LOCAL>/<cid>/<name>`, and its
        companions (`StorageClient.companions`: an index, a sequence
        dictionary) land in the same directory, where tools look for them.
        Outputs that record it as an input under another key, such as an
        Alignment's ``reference_cid``, stay behind. An asset built from a
        local file that was never uploaded is returned in place. Copies
        already on disk are reused; a companion file left by an earlier fetch
        is replaced when a different one of that name is picked now. A
        companion named like the asset itself is skipped.
        """
        import stargazer.utils.storage as _storage
        from stargazer.assets import specialize

        client = _storage.default_client
        local = await client.download(self)
        if self.cid:
            for record in await client.companions(self.cid):
                if record["name"] != local.name:
                    await client.download(specialize(record), local.parent)
        return local

    async def update(self, path: Path, **kwargs) -> None:
        """Store a local file as this asset, setting fields from kwargs first.

        Sets `cid` and `path` (the stored File) once the file is uploaded and
        its index row has committed. Raises if either fails.
        """
        import stargazer.utils.storage as _storage

        for key, value in kwargs.items():
            if value is not None:
                setattr(self, key, value)
        await _storage.default_client.upload(self, Path(path))

__init_subclass__(**kwargs)

Register subclass in the asset registry.

Source code in src/stargazer/assets/asset.py
def __init_subclass__(cls, **kwargs):
    """Register subclass in the asset registry."""
    super().__init_subclass__(**kwargs)
    ak = cls.__dict__.get("_asset_key", "")
    if ak:
        Asset._registry[ak] = cls

__setattr__(name, value)

Enforce declared fields on typed subclasses and coerce path to a File.

Source code in src/stargazer/assets/asset.py
def __setattr__(self, name: str, value: Any) -> None:
    """Enforce declared fields on typed subclasses and coerce `path` to a File."""
    if name == "path":
        value = _as_file(value)
    elif self._asset_key and not name.startswith("_") and name not in _BASE_FIELDS:
        allowed = self.declared_fields()
        if name not in allowed:
            raise AttributeError(
                f"{type(self).__name__} has no field '{name}'. "
                f"Allowed: {sorted(allowed)}"
            )
    super().__setattr__(name, value)

declared_fields() classmethod

Names of the fields this asset type declares, in declaration order.

These are the typed keyvalues it reads and writes: every dataclass field except the base cid, path and keyvalues. Bare Asset declares none.

Source code in src/stargazer/assets/asset.py
@classmethod
def declared_fields(cls) -> tuple[str, ...]:
    """Names of the fields this asset type declares, in declaration order.

    These are the typed keyvalues it reads and writes: every dataclass
    field except the base ``cid``, ``path`` and ``keyvalues``. Bare Asset
    declares none.
    """
    return tuple(
        f.name for f in dataclasses.fields(cls) if f.name not in _BASE_FIELDS
    )

fetch() async

Make a local copy of this asset and its companions; return its path.

The asset lands at <STARGAZER_LOCAL>/<cid>/<name>, and its companions (StorageClient.companions: an index, a sequence dictionary) land in the same directory, where tools look for them. Outputs that record it as an input under another key, such as an Alignment's reference_cid, stay behind. An asset built from a local file that was never uploaded is returned in place. Copies already on disk are reused; a companion file left by an earlier fetch is replaced when a different one of that name is picked now. A companion named like the asset itself is skipped.

Source code in src/stargazer/assets/asset.py
async def fetch(self) -> Path:
    """Make a local copy of this asset and its companions; return its path.

    The asset lands at `<STARGAZER_LOCAL>/<cid>/<name>`, and its
    companions (`StorageClient.companions`: an index, a sequence
    dictionary) land in the same directory, where tools look for them.
    Outputs that record it as an input under another key, such as an
    Alignment's ``reference_cid``, stay behind. An asset built from a
    local file that was never uploaded is returned in place. Copies
    already on disk are reused; a companion file left by an earlier fetch
    is replaced when a different one of that name is picked now. A
    companion named like the asset itself is skipped.
    """
    import stargazer.utils.storage as _storage
    from stargazer.assets import specialize

    client = _storage.default_client
    local = await client.download(self)
    if self.cid:
        for record in await client.companions(self.cid):
            if record["name"] != local.name:
                await client.download(specialize(record), local.parent)
    return local

from_dict(data) classmethod

Reconstruct from a serialized dict.

Source code in src/stargazer/assets/asset.py
@classmethod
def from_dict(cls, data: dict) -> Self:
    """Reconstruct from a serialized dict."""
    path = None
    if data.get("path"):
        path = _as_file(data["path"])
        if data.get("name"):
            path = File(path=path.path, name=data["name"])
    return cls.from_keyvalues(
        data.get("keyvalues", {}), cid=data.get("cid", ""), path=path
    )

from_keyvalues(kv, cid='', path=None) classmethod

Reconstruct from a storage keyvalues dict.

str fields are assigned directly; all other types are deserialized with json.loads (raising json.JSONDecodeError on values that don't parse — callers that must tolerate malformed records catch it, see specialize()). On a typed class, every key that isn't asset or a declared field becomes an extra in keyvalues. Bare Asset receives the keyvalues verbatim.

Source code in src/stargazer/assets/asset.py
@classmethod
def from_keyvalues(
    cls, kv: dict[str, str], cid: str = "", path: File | Path | str | None = None
) -> Asset:
    """Reconstruct from a storage keyvalues dict.

    str fields are assigned directly; all other types are deserialized
    with json.loads (raising ``json.JSONDecodeError`` on values that
    don't parse — callers that must tolerate malformed records catch it,
    see ``specialize()``). On a typed class, every key that isn't
    ``asset`` or a declared field becomes an extra in ``keyvalues``.
    Bare Asset receives the keyvalues verbatim.
    """
    if not cls._asset_key:
        return cls(cid=cid, path=path, keyvalues=dict(kv))
    hints = get_type_hints(cls)
    declared = cls.declared_fields()
    kwargs = {
        name: value if hints.get(name) is str else json.loads(value)
        for name, value in kv.items()
        if name in declared
    }
    extras = {k: v for k, v in kv.items() if k not in declared and k != "asset"}
    return cls(cid=cid, path=path, keyvalues=extras, **kwargs)

to_dict()

Serialize to a JSON-friendly dict: the file's location and name.

Source code in src/stargazer/assets/asset.py
def to_dict(self) -> dict:
    """Serialize to a JSON-friendly dict: the file's location and name."""
    return {
        "cid": self.cid,
        "path": self.path.path if self.path else None,
        "name": self.path.name if self.path else None,
        "keyvalues": self.to_keyvalues(),
    }

to_keyvalues()

Serialize to storage format.

str fields pass through as-is; all other types are serialized with json.dumps. A typed asset's extra keys are written beside its declared fields. Bare Asset instances return their keyvalues verbatim. The result is always a copy, so callers (e.g. _owner stamping at the storage layer) can mutate it without touching the Asset.

Raises:

Type Description
ValueError

when a typed asset's keyvalues holds asset or one of its declared fields, which are set as attributes

Source code in src/stargazer/assets/asset.py
def to_keyvalues(self) -> dict[str, str]:
    """Serialize to storage format.

    str fields pass through as-is; all other types are serialized with
    json.dumps. A typed asset's extra keys are written beside its
    declared fields. Bare Asset instances return their keyvalues
    verbatim. The result is always a copy, so callers (e.g. ``_owner``
    stamping at the storage layer) can mutate it without touching the
    Asset.

    Raises:
        ValueError: when a typed asset's ``keyvalues`` holds ``asset``
            or one of its declared fields, which are set as attributes
    """
    if not self._asset_key:
        return dict(self.keyvalues)
    declared = self.declared_fields()
    clash = sorted(set(self.keyvalues) & {"asset", *declared})
    if clash:
        raise ValueError(
            f"{type(self).__name__}.keyvalues holds {clash}, which "
            f"{type(self).__name__} sets itself: assign the attribute instead"
        )
    hints = get_type_hints(type(self))
    result: dict[str, str] = {"asset": self._asset_key}
    for name in declared:
        val = getattr(self, name)
        result[name] = val if hints.get(name) is str else json.dumps(val)
    result.update(self.keyvalues)
    return result

update(path, **kwargs) async

Store a local file as this asset, setting fields from kwargs first.

Sets cid and path (the stored File) once the file is uploaded and its index row has committed. Raises if either fails.

Source code in src/stargazer/assets/asset.py
async def update(self, path: Path, **kwargs) -> None:
    """Store a local file as this asset, setting fields from kwargs first.

    Sets `cid` and `path` (the stored File) once the file is uploaded and
    its index row has committed. Raises if either fails.
    """
    import stargazer.utils.storage as _storage

    for key, value in kwargs.items():
        if value is not None:
            setattr(self, key, value)
    await _storage.default_client.upload(self, Path(path))

assemble(**filters) async

Query storage by keyvalue filters and return specialized assets.

Every filter must match exactly. A list value matches any of its entries (asset=["r1", "r2"]). Searches the user's index and, when a Pinata key is configured, the public tier; one asset per CID.

Parameters:

Name Type Description Default
**filters Any

Keyvalue filters; values are strings or lists of strings

{}

Returns:

Type Description
list[Asset]

Flat list of specialized Asset subclass instances.

Examples:

assets = await assemble(build="GRCh38", asset="reference") ref = next(a for a in assets if isinstance(a, Reference))

assets = await assemble(sample_id="NA12878", asset=["r1", "r2"]) r1 = next(a for a in assets if isinstance(a, R1))

Source code in src/stargazer/assets/asset.py
async def assemble(**filters: Any) -> list[Asset]:
    """Query storage by keyvalue filters and return specialized assets.

    Every filter must match exactly. A list value matches any of its entries
    (`asset=["r1", "r2"]`). Searches the user's index and, when a Pinata key
    is configured, the public tier; one asset per CID.

    Args:
        **filters: Keyvalue filters; values are strings or lists of strings

    Returns:
        Flat list of specialized Asset subclass instances.

    Examples:
        assets = await assemble(build="GRCh38", asset="reference")
        ref = next(a for a in assets if isinstance(a, Reference))

        assets = await assemble(sample_id="NA12878", asset=["r1", "r2"])
        r1 = next(a for a in assets if isinstance(a, R1))
    """
    import stargazer.utils.storage as _storage
    from stargazer.assets import specialize

    records = await _storage.default_client.query(filters)
    return [specialize(r) for r in records]

Notebook asset type for Stargazer.

spec: docs/architecture/types.md

Notebook dataclass

Bases: Asset

A marimo notebook published from a user's workspace to the public tier.

A workspace notebook is a plain file until its owner publishes it from the dashboard, which is the only thing that creates one. title and description are the notebook's declared main_name and main_description, and slug is its workspace filename's stem, which ties the public record to the notebook it came from.

Source code in src/stargazer/assets/notebook.py
@dataclass
class Notebook(Asset):
    """A marimo notebook published from a user's workspace to the public tier.

    A workspace notebook is a plain file until its owner publishes it from
    the dashboard, which is the only thing that creates one. `title` and
    `description` are the notebook's declared `main_name` and
    `main_description`, and `slug` is its workspace filename's stem, which
    ties the public record to the notebook it came from.
    """

    _asset_key: ClassVar[str] = "notebook"
    title: str = ""
    description: str = ""
    slug: str = ""

Read file asset types for Stargazer.

spec: docs/architecture/types.md

R1 dataclass

Bases: Asset

R1 (forward) FASTQ read file asset.

Carries mate_cid pointing to the paired R2 asset's CID (empty for single-end).

Source code in src/stargazer/assets/reads.py
@dataclass
class R1(Asset):
    """R1 (forward) FASTQ read file asset.

    Carries mate_cid pointing to the paired R2 asset's CID (empty for single-end).
    """

    _asset_key: ClassVar[str] = "r1"
    sample_id: str = ""
    mate_cid: str = ""
    sequencing_platform: str = ""

R2 dataclass

Bases: Asset

R2 (reverse) FASTQ read file asset.

Carries mate_cid pointing to the paired R1 asset's CID (empty for single-end).

Source code in src/stargazer/assets/reads.py
@dataclass
class R2(Asset):
    """R2 (reverse) FASTQ read file asset.

    Carries mate_cid pointing to the paired R1 asset's CID (empty for single-end).
    """

    _asset_key: ClassVar[str] = "r2"
    sample_id: str = ""
    mate_cid: str = ""
    sequencing_platform: str = ""

Reference genome asset types for Stargazer.

spec: docs/architecture/types.md

AlignerIndex dataclass

Bases: Asset

Aligner index file asset (one file per index file for multi-file indices).

A companion: companion_of is the CID of the Reference it indexes.

Source code in src/stargazer/assets/reference.py
@dataclass
class AlignerIndex(Asset):
    """Aligner index file asset (one file per index file for multi-file indices).

    A companion: `companion_of` is the CID of the Reference it indexes.
    """

    _asset_key: ClassVar[str] = "aligner_index"
    build: str = ""
    aligner: str = ""
    companion_of: str = ""

Reference dataclass

Bases: Asset

Reference FASTA file asset.

Source code in src/stargazer/assets/reference.py
@dataclass
class Reference(Asset):
    """Reference FASTA file asset."""

    _asset_key: ClassVar[str] = "reference"
    build: str = ""

    async def contigs(self) -> list[str]:
        """Read contig names from the companion .fai index.

        Fetches the reference first, which brings the ReferenceIndex
        companion alongside the local copy, so it works in any pod.
        """
        path = await self.fetch()
        fai_path = path.with_name(path.name + ".fai")
        if not fai_path.exists():
            raise FileNotFoundError(
                f"Reference index not found at {fai_path}. Run samtools_faidx first."
            )
        async with aiofiles.open(fai_path) as f:
            lines = (await f.read()).splitlines()
        return [line.split("\t", 1)[0].strip() for line in lines if line.strip()]

contigs() async

Read contig names from the companion .fai index.

Fetches the reference first, which brings the ReferenceIndex companion alongside the local copy, so it works in any pod.

Source code in src/stargazer/assets/reference.py
async def contigs(self) -> list[str]:
    """Read contig names from the companion .fai index.

    Fetches the reference first, which brings the ReferenceIndex
    companion alongside the local copy, so it works in any pod.
    """
    path = await self.fetch()
    fai_path = path.with_name(path.name + ".fai")
    if not fai_path.exists():
        raise FileNotFoundError(
            f"Reference index not found at {fai_path}. Run samtools_faidx first."
        )
    async with aiofiles.open(fai_path) as f:
        lines = (await f.read()).splitlines()
    return [line.split("\t", 1)[0].strip() for line in lines if line.strip()]

ReferenceIndex dataclass

Bases: Asset

FASTA index (.fai) file asset.

A companion: companion_of is the CID of the Reference it indexes.

Source code in src/stargazer/assets/reference.py
@dataclass
class ReferenceIndex(Asset):
    """FASTA index (.fai) file asset.

    A companion: `companion_of` is the CID of the Reference it indexes.
    """

    _asset_key: ClassVar[str] = "reference_index"
    build: str = ""
    tool: str = ""
    companion_of: str = ""

SequenceDict dataclass

Bases: Asset

Sequence dictionary (.dict) file asset.

A companion: companion_of is the CID of the Reference it describes.

Source code in src/stargazer/assets/reference.py
@dataclass
class SequenceDict(Asset):
    """Sequence dictionary (.dict) file asset.

    A companion: `companion_of` is the CID of the Reference it describes.
    """

    _asset_key: ClassVar[str] = "sequence_dict"
    build: str = ""
    tool: str = ""
    companion_of: str = ""

scRNA-seq asset types for Stargazer.

spec: docs/architecture/types.md

AnnData dataclass

Bases: Asset

AnnData (.h5ad) file asset for single-cell RNA-seq data.

Tracks pipeline stage, cell/gene counts, and provenance through the scRNA-seq processing steps.

Source code in src/stargazer/assets/scrna.py
@dataclass
class AnnData(Asset):
    """AnnData (.h5ad) file asset for single-cell RNA-seq data.

    Tracks pipeline stage, cell/gene counts, and provenance through
    the scRNA-seq processing steps.
    """

    _asset_key: ClassVar[str] = "anndata"
    sample_id: str = ""
    n_obs: int = 0
    n_vars: int = 0
    stage: str = ""
    organism: str = ""
    source_cid: str = ""

Variant call asset types for Stargazer.

spec: docs/architecture/types.md

KnownSites dataclass

Bases: Asset

Known variant sites VCF: BQSR known sites, or a VQSR training resource.

resource_name, known, training, truth and prior are the VQSR resource arguments ("true"/"false" strings and a numeric prior).

Source code in src/stargazer/assets/variants.py
@dataclass
class KnownSites(Asset):
    """Known variant sites VCF: BQSR known sites, or a VQSR training resource.

    `resource_name`, `known`, `training`, `truth` and `prior` are the VQSR
    resource arguments (`"true"`/`"false"` strings and a numeric prior).
    """

    _asset_key: ClassVar[str] = "known_sites"
    build: str = ""
    resource_name: str = ""
    known: str = "false"
    training: str = "false"
    truth: str = "false"
    prior: str = "10"
    vqsr_mode: str = ""

KnownSitesIndex dataclass

Bases: Asset

VCF index (.idx) file for a KnownSites asset.

A companion: companion_of is the CID of the KnownSites VCF it indexes.

Source code in src/stargazer/assets/variants.py
@dataclass
class KnownSitesIndex(Asset):
    """VCF index (.idx) file for a KnownSites asset.

    A companion: `companion_of` is the CID of the KnownSites VCF it indexes.
    """

    _asset_key: ClassVar[str] = "known_sites_index"
    companion_of: str = ""

VQSRModel dataclass

Bases: Asset

VQSR recalibration model: the .recal file VariantRecalibrator writes.

ApplyVQSR needs two more files from the same run, stored as its companions: the recal file's index (VQSRModelIndex) and the tranches file (VQSRTranches).

Source code in src/stargazer/assets/variants.py
@dataclass
class VQSRModel(Asset):
    """VQSR recalibration model: the .recal file VariantRecalibrator writes.

    ApplyVQSR needs two more files from the same run, stored as its
    companions: the recal file's index (`VQSRModelIndex`) and the tranches
    file (`VQSRTranches`).
    """

    _asset_key: ClassVar[str] = "vqsr_model"
    sample_id: str = ""
    mode: str = "SNP"
    build: str = ""
    variants_cid: str = ""

VQSRModelIndex dataclass

Bases: Asset

VCF index (.idx) for a VQSRModel's recal file.

A companion: companion_of is the CID of the VQSRModel it indexes.

Source code in src/stargazer/assets/variants.py
@dataclass
class VQSRModelIndex(Asset):
    """VCF index (.idx) for a VQSRModel's recal file.

    A companion: `companion_of` is the CID of the VQSRModel it indexes.
    """

    _asset_key: ClassVar[str] = "vqsr_model_index"
    sample_id: str = ""
    companion_of: str = ""

VQSRTranches dataclass

Bases: Asset

VQSR tranches file: the VQSLOD cutoff for each truth-sensitivity level.

A companion: companion_of is the CID of the VQSRModel it was built with.

Source code in src/stargazer/assets/variants.py
@dataclass
class VQSRTranches(Asset):
    """VQSR tranches file: the VQSLOD cutoff for each truth-sensitivity level.

    A companion: `companion_of` is the CID of the VQSRModel it was built
    with.
    """

    _asset_key: ClassVar[str] = "vqsr_tranches"
    sample_id: str = ""
    companion_of: str = ""

Variants dataclass

Bases: Asset

VCF/GVCF variant call file asset.

Source code in src/stargazer/assets/variants.py
@dataclass
class Variants(Asset):
    """VCF/GVCF variant call file asset."""

    _asset_key: ClassVar[str] = "variants"
    sample_id: str = ""
    caller: str = ""
    variant_type: str = ""
    build: str = ""
    vqsr_mode: str = ""
    sample_count: int = 0
    source_samples: list = None

VariantsIndex dataclass

Bases: Asset

VCF index file asset (.idx, or .tbi for a bgzipped VCF).

A companion: companion_of is the CID of the Variants file it indexes.

Source code in src/stargazer/assets/variants.py
@dataclass
class VariantsIndex(Asset):
    """VCF index file asset (.idx, or .tbi for a bgzipped VCF).

    A companion: `companion_of` is the CID of the Variants file it indexes.
    """

    _asset_key: ClassVar[str] = "variants_index"
    sample_id: str = ""
    companion_of: str = ""

Config

Centralized configuration for Stargazer.

Sets environment variable defaults at import time. Consumers read os.environ directly rather than importing named values from this module.

Also the source of truth for the lean per-task Flyte environments (scrna_env, gatk_env).

init_flyte() is the one Flyte init for notebooks, the MCP server and the tests: an in-cluster client in a hosted notebook pod, the config file anywhere else.

Rules: - PINATA_JWT: No default — absence means no public tier (Pinata) at all. - PINATA_GATEWAY: IPFS gateway for public downloads. Defaults to the project's dedicated Pinata gateway. - STARGAZER_LOCAL: Scratch space for task outputs and the download cache. Defaults to ~/.stargazer/local. - STARGAZER_STORE_ROOT: Where asset bytes are stored — a local directory or a bucket URI (e.g. s3://stargazerbio-users). Defaults to ~/.stargazer/store. - STARGAZER_INDEX_URL: The asset index — a SQLite file path, a postgresql:// URL (the devbox's), or a dsql:// URL naming a user's table on Aurora DSQL (Union's). Defaults to ~/.stargazer/index.db. - STARGAZER_OWNER: The Union subject that owns new assets (their folder in the store, and _owner). Unset locally. - STARGAZER_API_KEY_SECRET: The Flyte secret holding the project's API key. When it's set, every environment declares that secret, as the variable Flyte's in-cluster client reads its key from, so a pod can call the cluster as its project. Union needs one; a cluster without a login (the devbox), or a local run, leaves it unset. Forwarded into every pod.

Where images are pushed is Flyte's own setting, FLYTE_IMAGE_REGISTRY. Flyte fixes an image's registry when the image is defined, so the environments here take it at import, not from the config a later init reads.

spec: docs/architecture/configuration.md

init_flyte()

Initialize Flyte wherever Stargazer runs.

A hosted notebook pod (_U_EP_OVERRIDE set) has no config file, but it carries the endpoint, an API key and the owner's org, project and domain, which make an in-cluster client for the owner's project. Its runs bundle code from the directory holding the stargazer package, where the SDK and the image-baked notebooks' tasks live. Anywhere else the config file decides, as flyte.init_from_config() does: with none, runs stay in the caller's own process.

Source code in src/stargazer/config.py
def init_flyte() -> None:
    """Initialize Flyte wherever Stargazer runs.

    A hosted notebook pod (`_U_EP_OVERRIDE` set) has no config file, but it
    carries the endpoint, an API key and the owner's org, project and domain,
    which make an in-cluster client for the owner's project. Its runs bundle
    code from the directory holding the `stargazer` package, where the SDK and
    the image-baked notebooks' tasks live. Anywhere else the config file
    decides, as `flyte.init_from_config()` does: with none, runs stay in the
    caller's own process.
    """
    if not os.environ.get("_U_EP_OVERRIDE"):
        flyte.init_from_config()
        return
    # `init_in_cluster` takes the code bundle's root from the cwd and has no
    # parameter for it. A pod's cwd is the workspace, which holds no SDK code.
    with contextlib.chdir(Path(__file__).resolve().parents[1]):
        flyte.init_in_cluster(
            org=os.environ.get("FLYTE_ORG"),
            project=os.environ.get("FLYTE_PROJECT"),
            domain=os.environ.get("FLYTE_DOMAIN"),
        )

log_execution()

Start a per-execution log sink and return the execution ID.

Derives the workflow name from the calling function, fetches the current git commit hash, and creates a dedicated logfile for this execution. Warns if the git tree has uncommitted changes.

Source code in src/stargazer/config.py
def log_execution() -> str:
    """Start a per-execution log sink and return the execution ID.

    Derives the workflow name from the calling function, fetches the current
    git commit hash, and creates a dedicated logfile for this execution.
    Warns if the git tree has uncommitted changes.
    """
    workflow = inspect.currentframe().f_back.f_code.co_name
    timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")

    try:
        result = subprocess.run(
            ["git", "rev-parse", "--short", "HEAD"],
            capture_output=True,
            text=True,
            check=False,  # missing/!git dir falls back to "unknown"
        )
        commit = result.stdout.strip() or "unknown"

        status = subprocess.run(
            ["git", "status", "--porcelain"],
            capture_output=True,
            text=True,
            check=False,  # non-repo is tolerated, not fatal
        )
        if status.stdout.strip():
            commit += "-dirty"
            logger.warning("Git tree is dirty — uncommitted changes present")
    except FileNotFoundError:
        commit = "unknown"

    execution_id = f"{workflow}-{commit}-{timestamp}"
    logger.add(_log_dir / f"{execution_id}.log")

    public = "on" if os.environ.get("PINATA_JWT") else "off"
    logger.info(
        f"Execution started: {execution_id} "
        f"| store={os.environ['STARGAZER_STORE_ROOT']} "
        f"| index={os.environ['STARGAZER_INDEX_URL']} "
        f"| public tier={public} "
        f"| gateway={os.environ['PINATA_GATEWAY']} "
        f"| local_dir={os.environ['STARGAZER_LOCAL']}"
    )
    return execution_id

Marshal

Output marshaling: typed object → dict (for MCP response serialization).

spec: docs/architecture/mcp-server.md

marshal_output(value)

Convert a typed Python object to a JSON-friendly structure for MCP transport.

Source code in src/stargazer/marshal.py
def marshal_output(value: Any) -> Any:
    """Convert a typed Python object to a JSON-friendly structure for MCP transport."""
    if value is None:
        return None

    if hasattr(value, "to_dict"):
        return value.to_dict()

    if isinstance(value, Path):
        return str(value)

    if isinstance(value, tuple):
        return {f"o{i}": marshal_output(item) for i, item in enumerate(value)}

    if isinstance(value, list):
        return [marshal_output(item) for item in value]

    if isinstance(value, dict):
        return {k: marshal_output(v) for k, v in value.items()}

    return value

Notebooks

Basic template — barebones notebook skeleton.

A choose-your-own-adventure scaffold for authoring your own notebook from scratch: pick the image it runs on, ingest a file, define an asset for it, process it with a task, and fan that task out in a workflow. Each section is a TODO-style template — pair with assets.py and tasks.py for the why and the deeper patterns.

spec: docs/architecture/notebook.md

MyAsset dataclass

Bases: Asset

TODO: describe your file type.

Source code in src/stargazer/notebooks/templates/basic.py
@app.class_definition
@dataclass
class MyAsset(Asset):
    """TODO: describe your file type."""

    _asset_key: ClassVar[str] = "my_asset"  # TODO: rename

_(dispatch, my_asset, where) async

Run the workflow on your uploaded asset.

Source code in src/stargazer/notebooks/templates/basic.py
@app.cell
async def _(dispatch, my_asset, where):
    """Run the workflow on your uploaded asset."""
    results = await dispatch(where, my_workflow, assets=[my_asset])
    mo.ui.table(
        [{"cid": _r.cid, "path": _r.path.path} for _r in results],
        selection=None,
    )

my_task(asset) async

TODO: do work on the asset's file and return a (new) asset.

Source code in src/stargazer/notebooks/templates/basic.py
@app.function
@my_env.task
async def my_task(asset: MyAsset) -> MyAsset:
    """TODO: do work on the asset's file and return a (new) asset."""
    local_path = await asset.fetch()  # noqa: F841 — the TODO below reads it
    # TODO: read local_path, write a new file, then either:
    #   1. mutate + re-upload this asset, OR
    #   2. construct a NEW asset and `await new.update(path=..., **kw)`
    return asset

my_workflow(assets) async

TODO: orchestrate. Default = fan my_task out across inputs.

Source code in src/stargazer/notebooks/templates/basic.py
@app.function
@my_env.task
async def my_workflow(assets: list[MyAsset]) -> list[MyAsset]:
    """TODO: orchestrate. Default = fan `my_task` out across inputs."""
    return list(await asyncio.gather(*[my_task(a) for a in assets]))

Blank workspace notebook.

An empty marimo notebook for authoring from scratch. /workspace/create copies this seed under your chosen name and declares it as main_name in the setup block. Edit main_env there to rightsize the pod for your workload.

spec: docs/architecture/notebook.md

_()

Imports.

Source code in src/stargazer/notebooks/templates/blank.py
@app.cell
def _():
    """Imports."""
    import marimo as mo

    return (mo,)

Stargazer asset and types system tutorial.

A guided walkthrough of why typed Asset subclasses exist, the three layers each Asset has (cid, path, fields), defining a new asset class, and the round-trip from Python object to storage and back.

SampleSheet is exported for the later tutorials.

spec: docs/architecture/types.md

SampleSheet dataclass

Bases: Asset

CSV of per-sample metadata for a cohort.

Source code in src/stargazer/notebooks/tutorials/assets.py
@app.class_definition
@dataclass
class SampleSheet(Asset):
    """CSV of per-sample metadata for a cohort."""

    _asset_key: ClassVar[str] = "sample_sheet"
    cohort_id: str = ""
    n_samples: int = 0
    organism: str = ""

_()

Section 8 — recap.

Source code in src/stargazer/notebooks/tutorials/assets.py
@app.cell
def _():
    """Section 8 — recap."""
    mo.md(
        """
        ## Recap

        | Concept | What it gives you |
        |---------|-------------------|
        | `Asset` base + `_asset_key` | A typed, registered, queryable file |
        | `cid` vs `path` | Identity vs location, decoupled |
        | `to_keyvalues()` / `from_keyvalues()` | Round-trip across a flat-string storage boundary |
        | `update(path, **kwargs)` | Single call: set fields, upload, get a CID |
        | `assemble(**filters)` | Discovery by metadata, not folders |
        | Companion pattern via `companion_of` | Index/data pairs travel together |

        That's the whole system.

        ### → Next: `tasks.py`

        The follow-on notebook **imports the `SampleSheet` you defined
        here** and wraps a Flyte task around it. From there the arc is:

        1. **Tasks** — define one task over typed assets.
        2. **Workflows** — compose tasks into a fan-out workflow.
        3. **Execution** — the same code on a remote cluster, no changes.

        Each tutorial imports the previous one's objects, so the whole
        sequence runs on a single shared `SampleSheet`. Open `tasks.py`
        next.
        """
    )

Stargazer execution tutorial.

Assumes the asset/task/workflow primitives from the earlier tutorials and spends them on the headline lesson: running a workflow first locally, then on a remote cluster with no code changes. It imports the very same audit_cohorts workflow composed in workflows.py and runs it both ways, charting the result, then hands the choice to the run_location() control.

spec: docs/architecture/workflows.md

_(audit_cohorts, dispatch, mo, sheets, summaries_local, where) async

Run audit_cohorts wherever the control says.

Source code in src/stargazer/notebooks/tutorials/execution.py
@app.cell
async def _(audit_cohorts, dispatch, mo, sheets, summaries_local, where):
    """Run audit_cohorts wherever the control says."""
    with mo.status.spinner(title="Running audit_cohorts..."):
        _summaries = await dispatch(where, audit_cohorts, sheets=sheets)

    _same = sorted(s.cid for s in _summaries) == sorted(s.cid for s in summaries_local)
    mo.md(
        f"""
        Got back **{len(_summaries)}** `CohortSummary` results.
        Same CIDs as section 3 → **{_same}**
        """
    )

Stargazer tasks tutorial.

Imports the SampleSheet asset from the assets tutorial and walks through defining a single Flyte task that consumes it and where task environments live. Running the task — locally and remotely — is saved for the execution tutorial; composing it into a workflow is the one before that.

CohortSummary, tutorial_env, and summarize_cohort are exported for the later tutorials.

spec: docs/architecture/tasks.md

CohortSummary dataclass

Bases: Asset

JSON summary of a cohort: per-organism counts and totals.

Source code in src/stargazer/notebooks/tutorials/tasks.py
@app.class_definition
@dataclass
class CohortSummary(Asset):
    """JSON summary of a cohort: per-organism counts and totals."""

    _asset_key: ClassVar[str] = "cohort_summary"
    cohort_id: str = ""
    n_samples: int = 0
    n_organisms: int = 0
    sample_sheet_cid: str = ""

_()

Recap.

Source code in src/stargazer/notebooks/tutorials/tasks.py
@app.cell
def _():
    """Recap."""
    mo.md(
        """
        ## Recap

        | What you did | What you got |
        |--------------|--------------|
        | `@tutorial_env.task` on a function | A unit of work Flyte can run anywhere |
        | Typed `SampleSheet` in, `CohortSummary` out | Self-describing I/O, automatic storage |
        | `await summary.update(...)` inside the task | Upload + CID assignment in one call |

        ### → Next: `workflows.py`

        One task is the primitive. The next notebook **imports
        `summarize_cohort`** and composes it into a **workflow** — a task
        that awaits other tasks — fanning it out across many cohorts with
        `asyncio.gather`. After that, the Execution tutorial runs the
        whole thing locally and on a remote cluster.
        """
    )

summarize_cohort(sheet) async

Count samples and unique organisms in a cohort sample sheet.

Source code in src/stargazer/notebooks/tutorials/tasks.py
@app.function
@tutorial_env.task
async def summarize_cohort(sheet: SampleSheet) -> CohortSummary:
    """Count samples and unique organisms in a cohort sample sheet."""
    sheet_path = await sheet.fetch()

    with sheet_path.open() as fh:
        rows = list(csv.DictReader(fh))
    organisms = sorted({r["organism"] for r in rows if r.get("organism")})

    out_path = sheet_path.parent / f"{sheet.cohort_id}_summary.json"
    out_path.write_text(
        json.dumps(
            {
                "cohort_id": sheet.cohort_id,
                "n_samples": len(rows),
                "organisms": organisms,
            },
            indent=2,
        )
    )

    summary = CohortSummary()
    await summary.update(
        path=out_path,
        cohort_id=sheet.cohort_id,
        n_samples=len(rows),
        n_organisms=len(organisms),
        sample_sheet_cid=sheet.cid,
    )
    return summary

Stargazer workflows tutorial.

Imports the summarize_cohort task from the tasks tutorial and composes it into a fan-out workflow. In Flyte v2 a workflow is just a task that calls other tasks, so fan-out is plain asyncio.gather over task calls.

audit_cohorts is exported for the Execution tutorial.

spec: docs/architecture/workflows.md

_()

Recap and next.

Source code in src/stargazer/notebooks/tutorials/workflows.py
@app.cell
def _():
    """Recap and next."""
    mo.md(
        """
        ## Recap

        | What you did | What you got |
        |--------------|--------------|
        | Workflow = `@task` that awaits other tasks | Composition with no special syntax |
        | `asyncio.gather` over task calls | Parallel fan-out |
        | Imported task + assets, defined the workflow | One shared definition, no drift |

        This notebook only *defines* the workflow and its inputs —
        running them is the execution tutorial's job.

        ### → Next: `execution.py` (Execution)

        The Execution tutorial imports `audit_cohorts` and
        `make_demo_sheets` and runs the workflow **locally and then on a
        remote cluster with no code changes**, watching it execute
        through a live console URL.
        """
    )

audit_cohorts(sheets) async

Summarize many cohorts in parallel and return all results.

Source code in src/stargazer/notebooks/tutorials/workflows.py
@app.function
@tutorial_env.task
async def audit_cohorts(sheets: list[SampleSheet]) -> list[CohortSummary]:
    """Summarize many cohorts in parallel and return all results."""
    return list(await asyncio.gather(*[summarize_cohort(s) for s in sheets]))

make_demo_sheets() async

Build and upload three small cohort SampleSheet assets.

Reusable so the Execution tutorial imports the exact same inputs instead of rebuilding them.

Source code in src/stargazer/notebooks/tutorials/workflows.py
@app.function
async def make_demo_sheets() -> list[SampleSheet]:
    """Build and upload three small cohort `SampleSheet` assets.

    Reusable so the Execution tutorial imports the exact same inputs
    instead of rebuilding them.
    """
    tmpdir = Path(tempfile.mkdtemp(prefix="stargazer_demo_"))
    cohorts = [
        ("alpha", [("a1", "human"), ("a2", "human"), ("a3", "human")]),
        ("beta", [("b1", "human"), ("b2", "mouse"), ("b3", "mouse")]),
        ("gamma", [("g1", "human"), ("g2", "human")]),
    ]

    sheets = []
    for cohort_id, samples in cohorts:
        csv_path = tmpdir / f"{cohort_id}.csv"
        with csv_path.open("w", newline="") as fh:
            writer = csv.writer(fh)
            writer.writerow(["sample_id", "organism"])
            writer.writerows(samples)
        sheet = SampleSheet()
        await sheet.update(
            path=csv_path,
            cohort_id=f"demo_{cohort_id}",
            n_samples=len(samples),
            organism="mixed",
        )
        sheets.append(sheet)
    return sheets

Notebook controls for choosing where a run goes.

run_location() is a control a notebook drops into a cell, set to "On this machine" or "On the cluster", and dispatch() runs a task or workflow wherever it's set. Reading a control's value in the cell that made it is an error in marimo, so the control and the dispatch go in separate cells, and changing the control re-runs the dispatching cell:

where = run_location()
where

summaries = await dispatch(where, audit_cohorts, sheets=sheets)

A cluster run's code bundle comes from where the dispatched task's module was imported from: the notebook's own directory when the notebook defining it is the one running, as a workspace notebook in its pod is, or the package root for one imported by its dotted name. The task has to be importable there, which a task defined in a regular cell isn't: it goes in an @app.function.

A module of its own rather than part of the package __init__, because it imports marimo and the dashboard imports stargazer.notebooks without it.

spec: docs/architecture/notebook.md

dispatch(location, task, /, **inputs) async

Run task with inputs where location says, and return its output.

Waits for the run to finish either way, so the result is the value awaiting the task directly would give. Handles tasks with one output. "On the cluster" asks for a remote run outright: flyte.run would quietly run in-process when no cluster is configured. It bundles the run's code from task's own root (_bundle_root), and returns the output as the type task declares.

Source code in src/stargazer/notebooks/ui.py
async def dispatch(location: mo.ui.radio, task: flyte.TaskTemplate, /, **inputs):
    """Run `task` with `inputs` where `location` says, and return its output.

    Waits for the run to finish either way, so the result is the value awaiting
    the task directly would give. Handles tasks with one output. "On the
    cluster" asks for a remote run outright: `flyte.run` would quietly run
    in-process when no cluster is configured. It bundles the run's code from
    `task`'s own root (`_bundle_root`), and returns the output as the type
    `task` declares.
    """
    runner = flyte.with_runcontext(mode=location.value)
    if location.value == "local":
        run = await runner.run.aio(task, **inputs)
        await run.wait.aio()
        return (await run.outputs.aio())[0]
    # A context-scoped override, so samples dispatched together with
    # `asyncio.gather` each keep their own.
    with init_config_context(get_init_config().replace(root_dir=_bundle_root(task))):
        run = await runner.run.aio(task, **inputs)
    await run.wait.aio()
    # A remote run's `outputs()` rebuilds each value from its stored schema, as
    # a look-alike class without the real one's methods (an asset's `fetch`).
    outputs = await run.typed_outputs.aio(task.native_interface.outputs)
    return next(iter(outputs.values()))

run_location()

The control for where a notebook's runs go, starting on this machine.

Its value is the Flyte run mode the choice stands for, which is what dispatch passes on.

Source code in src/stargazer/notebooks/ui.py
def run_location() -> mo.ui.radio:
    """The control for where a notebook's runs go, starting on this machine.

    Its value is the Flyte run mode the choice stands for, which is what
    `dispatch` passes on.
    """
    return mo.ui.radio(
        options={"On this machine": "local", "On the cluster": "remote"},
        value="On this machine",
        inline=True,
        label="Run",
    )

Stargazer scRNA-seq.

Multi-sample fan-out preprocessing plus interactive clustering, side-by-side UMAPs, and marker-gene tables. The workflow-tier showcase: the same primitives the tutorials teach, applied as a full-fat scRNA-seq pipeline.

spec: docs/architecture/notebook.md

_()

Links to the tutorial sequence.

Source code in src/stargazer/notebooks/workflows/scrna_pipeline.py
@app.cell
def _():
    """Links to the tutorial sequence."""
    mo.md(
        """
        ## Next Steps

        The tutorials on your dashboard build up the primitives behind
        this pipeline:

        - **Assets** — typed, content-addressed I/O
        - **Tasks** — single units of work
        - **Workflows** — composing tasks
        - **Execution** — local vs remote
        """
    )

cluster_and_annotate(reduced, resolution) async

Leiden clustering followed by marker gene identification.

Source code in src/stargazer/notebooks/workflows/scrna_pipeline.py
@app.function
@pipeline_env.task(cache="disable")
async def cluster_and_annotate(reduced: AnnData, resolution: float) -> AnnData:
    """Leiden clustering followed by marker gene identification."""
    clustered = await cluster(adata=reduced, resolution=resolution)
    return await find_markers(adata=clustered)

preprocess(raw, max_pct_mt, n_top_genes) async

QC, normalize, select HVGs, and reduce dimensions for one sample.

Source code in src/stargazer/notebooks/workflows/scrna_pipeline.py
@app.function
@pipeline_env.task(cache="disable")
async def preprocess(raw: AnnData, max_pct_mt: float, n_top_genes: int) -> AnnData:
    """QC, normalize, select HVGs, and reduce dimensions for one sample."""
    filtered = await qc_filter(adata=raw, max_pct_mt=max_pct_mt)
    normalized = await normalize(adata=filtered)
    featured = await select_features(adata=normalized, n_top_genes=n_top_genes)
    return await reduce_dimensions(adata=featured)

Registry

Task registry for auto-discovery of Flyte tasks and workflows.

Discovers the tasks exported by stargazer.tasks and the workflows exported by stargazer.workflows (their __all__), and extracts parameter types, defaults, and return types for the MCP catalog. A task a package doesn't export isn't discovered.

spec: docs/architecture/mcp-server.md

TaskInfo dataclass

Complete metadata about a registered task.

Source code in src/stargazer/registry.py
@dataclass
class TaskInfo:
    """Complete metadata about a registered task."""

    name: str
    category: str  # "task" or "workflow"
    description: str
    params: list[TaskParam]
    outputs: list[TaskOutput]
    task_obj: Any  # The Flyte task object

TaskOutput dataclass

Describes a single output of a task.

Source code in src/stargazer/registry.py
@dataclass
class TaskOutput:
    """Describes a single output of a task."""

    name: str
    type_hint: Any
    type_name: str

TaskParam dataclass

Describes a single parameter of a task.

Source code in src/stargazer/registry.py
@dataclass
class TaskParam:
    """Describes a single parameter of a task."""

    name: str
    type_hint: Any
    type_name: str
    required: bool
    default: Any = None

TaskRegistry dataclass

Discovers and provides access to all Flyte tasks and workflows.

Source code in src/stargazer/registry.py
@dataclass
class TaskRegistry:
    """Discovers and provides access to all Flyte tasks and workflows."""

    _tasks: dict[str, TaskInfo] = field(default_factory=dict)

    def __post_init__(self):
        """Discover all tasks and workflows on initialization."""
        import stargazer.tasks
        import stargazer.workflows

        self._discover_from(stargazer.tasks, "task")
        self._discover_from(stargazer.workflows, "workflow")

    def _discover_from(self, module: Any, category: str):
        """Register every Flyte task in `module.__all__` under `category`."""
        for name in module.__all__:
            obj = getattr(module, name)
            if hasattr(obj, "func"):
                self._register(obj.short_name, obj, category=category)

    def _register(self, name: str, task_obj: Any, category: str):
        """Register a single task by introspecting its wrapped function."""
        func = task_obj.func
        sig = inspect.signature(func)
        hints = get_type_hints(func)

        params = []
        for pname, param in sig.parameters.items():
            hint = hints.get(pname, Any)
            has_default = param.default is not inspect.Parameter.empty
            params.append(
                TaskParam(
                    name=pname,
                    type_hint=hint,
                    type_name=_type_name(hint),
                    required=not has_default,
                    default=param.default if has_default else None,
                )
            )

        return_hint = hints.get("return", type(None))
        outputs = _parse_outputs(return_hint)

        description = (func.__doc__ or "").strip().split("\n")[0]

        self._tasks[name] = TaskInfo(
            name=name,
            category=category,
            description=description,
            params=params,
            outputs=outputs,
            task_obj=task_obj,
        )

    def get(self, name: str) -> TaskInfo | None:
        """Look up a task by name."""
        return self._tasks.get(name)

    def list_tasks(self, category: str | None = None) -> list[TaskInfo]:
        """List all registered tasks, optionally filtered by category."""
        tasks = list(self._tasks.values())
        if category:
            tasks = [t for t in tasks if t.category == category]
        return tasks

    def to_catalog(self, category: str | None = None) -> list[dict]:
        """Return a JSON-serializable catalog of all tasks."""
        catalog = []
        for info in self.list_tasks(category=category):
            catalog.append(
                {
                    "name": info.name,
                    "category": info.category,
                    "description": info.description,
                    "params": [
                        {
                            "name": p.name,
                            "type": p.type_name,
                            "required": p.required,
                            "default": _serialize_default(p.default),
                        }
                        for p in info.params
                    ],
                    "outputs": [
                        {"name": o.name, "type": o.type_name} for o in info.outputs
                    ],
                }
            )
        return catalog

__post_init__()

Discover all tasks and workflows on initialization.

Source code in src/stargazer/registry.py
def __post_init__(self):
    """Discover all tasks and workflows on initialization."""
    import stargazer.tasks
    import stargazer.workflows

    self._discover_from(stargazer.tasks, "task")
    self._discover_from(stargazer.workflows, "workflow")

get(name)

Look up a task by name.

Source code in src/stargazer/registry.py
def get(self, name: str) -> TaskInfo | None:
    """Look up a task by name."""
    return self._tasks.get(name)

list_tasks(category=None)

List all registered tasks, optionally filtered by category.

Source code in src/stargazer/registry.py
def list_tasks(self, category: str | None = None) -> list[TaskInfo]:
    """List all registered tasks, optionally filtered by category."""
    tasks = list(self._tasks.values())
    if category:
        tasks = [t for t in tasks if t.category == category]
    return tasks

to_catalog(category=None)

Return a JSON-serializable catalog of all tasks.

Source code in src/stargazer/registry.py
def to_catalog(self, category: str | None = None) -> list[dict]:
    """Return a JSON-serializable catalog of all tasks."""
    catalog = []
    for info in self.list_tasks(category=category):
        catalog.append(
            {
                "name": info.name,
                "category": info.category,
                "description": info.description,
                "params": [
                    {
                        "name": p.name,
                        "type": p.type_name,
                        "required": p.required,
                        "default": _serialize_default(p.default),
                    }
                    for p in info.params
                ],
                "outputs": [
                    {"name": o.name, "type": o.type_name} for o in info.outputs
                ],
            }
        )
    return catalog

Server

Stargazer MCP Server.

Exposes storage tools and a dynamic task runner via the MCP SDK's high-level MCPServer. Tasks and workflows are auto-discovered from the registry and executed with flyte.run: remotely when init_flyte() finds an endpoint in .flyte/config.yaml, in-process otherwise.

Usage

stargazer # stdio transport (default) stargazer --http # streamable-http transport

spec: docs/architecture/mcp-server.md

delete_file(cid) async

Delete a file by CID.

Source code in src/stargazer/server.py
@mcp.tool()
async def delete_file(cid: str) -> str:
    """Delete a file by CID."""
    await _storage.default_client.delete(Asset(cid=cid))
    return f"Deleted file {cid}"

download_file(cid) async

Download a file by CID to local cache. Returns the local path.

A CID that isn't in your index is treated as public and fetched from the IPFS gateway.

Source code in src/stargazer/server.py
@mcp.tool()
async def download_file(cid: str) -> str:
    """Download a file by CID to local cache. Returns the local path.

    A CID that isn't in your index is treated as public and fetched from the
    IPFS gateway.
    """
    return str(await _storage.default_client.download(Asset(cid=cid)))

fetch_resource_bundle(bundle_name) async

Download a predefined resource bundle into local storage.

Bundles are curated sets of public files (e.g. reference genomes, demo datasets) defined in the codebase. Each file is downloaded by CID from the public IPFS gateway and recorded in the user's index with the manifest's keyvalues, so assemble() and query_files find it afterwards, with or without a Pinata key.

Parameters:

Name Type Description Default
bundle_name str

Name of the bundle (from list_bundles).

required

Returns:

Type Description
list[dict]

List of fetched files with cid, name, keyvalues, local path, and

list[dict]

whether each was already cached.

Source code in src/stargazer/server.py
@mcp.tool()
async def fetch_resource_bundle(bundle_name: str) -> list[dict]:
    """Download a predefined resource bundle into local storage.

    Bundles are curated sets of public files (e.g. reference genomes, demo
    datasets) defined in the codebase. Each file is downloaded by CID from
    the public IPFS gateway and recorded in the user's index with the
    manifest's keyvalues, so assemble() and query_files find it afterwards,
    with or without a Pinata key.

    Args:
        bundle_name: Name of the bundle (from list_bundles).

    Returns:
        List of fetched files with cid, name, keyvalues, local path, and
        whether each was already cached.
    """
    return await _bundles.fetch_bundle(bundle_name)

list_bundles()

List available resource bundles.

Returns:

Type Description
list[dict]

List of bundles with name, description, and file_count.

Source code in src/stargazer/server.py
@mcp.tool()
def list_bundles() -> list[dict]:
    """List available resource bundles.

    Returns:
        List of bundles with name, description, and file_count.
    """
    return _bundles.list_bundles()

list_tasks(category=None)

List available tasks and workflows with their parameter signatures.

Parameters:

Name Type Description Default
category str | None

Filter by "task" or "workflow". Omit for all.

None

Returns:

Type Description
list[dict]

Catalog of tasks with name, category, description, params, and outputs.

Source code in src/stargazer/server.py
@mcp.tool()
def list_tasks(category: str | None = None) -> list[dict]:
    """List available tasks and workflows with their parameter signatures.

    Args:
        category: Filter by "task" or "workflow". Omit for all.

    Returns:
        Catalog of tasks with name, category, description, params, and outputs.
    """
    return _registry.to_catalog(category=category)

main()

Run the Stargazer MCP server.

Source code in src/stargazer/server.py
def main():
    """Run the Stargazer MCP server."""
    transport = "stdio"
    if "--http" in sys.argv:
        transport = "streamable-http"
    mcp.run(transport=transport)

publish_file(cid) async

Publish one of your stored files: anyone on the internet can then find and download it. The CID doesn't change, and your own copy stays.

Needs the public tier (a Pinata key). Refused for a CID that isn't in your storage, and for metadata over the public tier's limits (nine fields, 250 characters each), before anything is sent.

When displaying results, always show a table with the CID and all keyvalues.

Source code in src/stargazer/server.py
@mcp.tool()
async def publish_file(cid: str) -> dict:
    """Publish one of your stored files: anyone on the internet can then find
    and download it. The CID doesn't change, and your own copy stays.

    Needs the public tier (a Pinata key). Refused for a CID that isn't in your
    storage, and for metadata over the public tier's limits (nine fields, 250
    characters each), before anything is sent.

    When displaying results, always show a table with the CID and all keyvalues.
    """
    return await _storage.default_client.publish(cid)

query_files(keyvalues) async

Query files by metadata key-value pairs. Returns matching files.

Source code in src/stargazer/server.py
@mcp.tool()
async def query_files(keyvalues: dict[str, str]) -> list[dict]:
    """Query files by metadata key-value pairs. Returns matching files."""
    return await _storage.default_client.query(keyvalues)

run_task(task_name, filters, inputs=None) async

Run a single task by name for ad-hoc experimentation.

Use this for testing individual tools in isolation. Asset parameters are assembled from storage using the provided filters — one call to assemble() resolves all required assets, and every filter must match each asset, so assets with different keys (a reference's build, a sample's sample_id) can't be filled from one call. Scalar and Path parameters are passed separately via inputs.

For reproducible pipeline runs, use run_workflow instead.

Parameters:

Name Type Description Default
task_name str

Name of the task (from list_tasks with category="task").

required
filters dict

Keyvalue filters for assemble() to resolve asset parameters (e.g. {"asset": "reference", "build": "GRCh38"}); a list value matches any of its entries.

required
inputs dict | None

Optional scalar/Path keyword arguments (str, int, bool, list[str]).

None

Returns:

Type Description
dict

Serialized task output. Single outputs returned directly,

dict

multi-outputs as {"o0": ..., "o1": ...}.

Source code in src/stargazer/server.py
@mcp.tool()
async def run_task(task_name: str, filters: dict, inputs: dict | None = None) -> dict:
    """Run a single task by name for ad-hoc experimentation.

    Use this for testing individual tools in isolation. Asset parameters
    are assembled from storage using the provided filters — one call to
    assemble() resolves all required assets, and every filter must match
    each asset, so assets with different keys (a reference's build, a
    sample's sample_id) can't be filled from one call. Scalar and Path
    parameters are passed separately via inputs.

    For reproducible pipeline runs, use run_workflow instead.

    Args:
        task_name: Name of the task (from list_tasks with category="task").
        filters: Keyvalue filters for assemble() to resolve asset parameters
                 (e.g. {"asset": "reference", "build": "GRCh38"}); a list
                 value matches any of its entries.
        inputs: Optional scalar/Path keyword arguments (str, int, bool, list[str]).

    Returns:
        Serialized task output. Single outputs returned directly,
        multi-outputs as {"o0": ..., "o1": ...}.
    """
    info = _registry.get(task_name)
    if info is None:
        available = [t.name for t in _registry.list_tasks(category="task")]
        raise ValueError(f"Unknown task: {task_name!r}. Available: {available}")
    if info.category != "task":
        raise ValueError(f"{task_name!r} is a workflow — use run_workflow instead.")

    inputs = inputs or {}
    assets = await assemble(**filters) if filters else []

    kwargs = {}
    for p in info.params:
        asset_key = _asset_key_for_hint(p.type_hint)
        if asset_key:
            matched = [a for a in assets if a._asset_key == asset_key]
            if not matched and p.required:
                raise ValueError(
                    f"Task {task_name!r} requires {p.name} ({asset_key}) "
                    f"but no matching asset found for filters: {filters}"
                )
            if matched:
                kwargs[p.name] = (
                    matched if get_origin(p.type_hint) is list else matched[-1]
                )
        elif p.name in inputs:
            value = inputs[p.name]
            if p.type_hint is Path and isinstance(value, str):
                value = Path(value)
            kwargs[p.name] = value

    return await _execute(info, kwargs)

run_workflow(workflow_name, inputs) async

Run a workflow by name for reproducible pipeline execution.

Workflows accept scalar parameters (str, int, bool, list[str]) and handle their own asset assembly internally. Pass inputs exactly as the workflow signature defines them — no automatic resolution is performed.

For ad-hoc experimentation with individual tools, use run_task instead.

Parameters:

Name Type Description Default
workflow_name str

Name of the workflow (from list_tasks with category="workflow").

required
inputs dict

Keyword arguments as a JSON dict (scalars only).

required

Returns:

Type Description
dict

Serialized workflow output. Single outputs returned directly,

dict

multi-outputs as {"o0": ..., "o1": ...}.

Source code in src/stargazer/server.py
@mcp.tool()
async def run_workflow(workflow_name: str, inputs: dict) -> dict:
    """Run a workflow by name for reproducible pipeline execution.

    Workflows accept scalar parameters (str, int, bool, list[str]) and
    handle their own asset assembly internally. Pass inputs exactly as
    the workflow signature defines them — no automatic resolution is
    performed.

    For ad-hoc experimentation with individual tools, use run_task instead.

    Args:
        workflow_name: Name of the workflow (from list_tasks with category="workflow").
        inputs: Keyword arguments as a JSON dict (scalars only).

    Returns:
        Serialized workflow output. Single outputs returned directly,
        multi-outputs as {"o0": ..., "o1": ...}.
    """
    info = _registry.get(workflow_name)
    if info is None:
        available = [t.name for t in _registry.list_tasks(category="workflow")]
        raise ValueError(f"Unknown workflow: {workflow_name!r}. Available: {available}")
    if info.category != "workflow":
        raise ValueError(f"{workflow_name!r} is a task — use run_task instead.")

    return await _execute(info, dict(inputs))

show_config() async

Show current Stargazer configuration and available task counts.

Source code in src/stargazer/server.py
@mcp.resource("stargazer://config")
async def show_config() -> str:
    """Show current Stargazer configuration and available task counts."""
    tasks = _registry.list_tasks(category="task")
    workflows = _registry.list_tasks(category="workflow")
    config = {
        "pinata_jwt": "set" if os.environ.get("PINATA_JWT") else "unset",
        "store_root": os.environ["STARGAZER_STORE_ROOT"],
        "index": os.environ["STARGAZER_INDEX_URL"],
        "local_dir": os.environ["STARGAZER_LOCAL"],
        "tasks": len(tasks),
        "workflows": len(workflows),
    }
    return json.dumps(config, indent=2)

update_file(cid, keyvalues) async

Update (merge) metadata on an existing file by CID — fix a typo'd or mis-tagged record without re-uploading the bytes (the CID is unchanged).

keyvalues is a patch onto your own index row: the supplied keys are added or overwritten, keys you omit are preserved (there is no key removal). It must include "asset"; the patch validates through the same rules as upload (registered keys check their declared fields' values and accept extra keys as text; reserved underscore keys like _owner are stamped automatically and must not be supplied).

When displaying results, always show a table with the CID and all keyvalues.

Source code in src/stargazer/server.py
@mcp.tool()
async def update_file(cid: str, keyvalues: dict[str, str]) -> dict:
    """Update (merge) metadata on an existing file by CID — fix a typo'd or
    mis-tagged record without re-uploading the bytes (the CID is unchanged).

    keyvalues is a patch onto your own index row: the supplied keys are added
    or overwritten, keys you omit are preserved (there is no key removal). It must
    include "asset"; the patch validates through the same rules as upload
    (registered keys check their declared fields' values and accept extra
    keys as text; reserved underscore keys like _owner are stamped
    automatically and must not be supplied).

    When displaying results, always show a table with the CID and all keyvalues.
    """
    build_asset(keyvalues)  # validate the patch (raises ValueError on bad input)
    return await _storage.default_client.update_metadata(cid, keyvalues)

upload_file(path, keyvalues) async

Upload a file with metadata key-value pairs.

keyvalues must include "asset". Registered asset keys (e.g. asset=reference) check their declared fields' values, and keep any other key you supply (e.g. lane=3) as an extra text field on the typed asset, so tasks that take that type can still use the file. Unregistered keys are stored as generic assets with the keyvalues verbatim. Reserved system keys (underscore-prefixed, e.g. _owner) are stamped automatically and must not be supplied.

When displaying results, always show a table with the CID and all keyvalues.

Source code in src/stargazer/server.py
@mcp.tool()
async def upload_file(path: str, keyvalues: dict[str, str]) -> dict:
    """Upload a file with metadata key-value pairs.

    keyvalues must include "asset". Registered asset keys (e.g.
    asset=reference) check their declared fields' values, and keep any other
    key you supply (e.g. lane=3) as an extra text field on the typed asset,
    so tasks that take that type can still use the file. Unregistered keys
    are stored as generic assets with the keyvalues verbatim. Reserved
    system keys (underscore-prefixed, e.g. _owner) are stamped automatically
    and must not be supplied.

    When displaying results, always show a table with the CID and all keyvalues.
    """
    comp = build_asset(keyvalues)
    await _storage.default_client.upload(comp, Path(path))
    result = comp.to_dict()
    if type(comp) is Asset:
        result["note"] = (
            f"asset key {keyvalues['asset']!r} is not registered; stored as a "
            "generic asset"
        )
    return result

Tasks

apply_bqsr task for Stargazer.

Applies BQSR recalibration to BAM files using GATK ApplyBQSR.

spec: docs/architecture/tasks.md

apply_bqsr(alignment, ref, bqsr_report) async

Apply Base Quality Score Recalibration to a BAM file.

Parameters:

Name Type Description Default
alignment Alignment

Input BAM asset

required
ref Reference

Reference FASTA asset

required
bqsr_report BQSRReport

Recalibration table from base_recalibrator

required

Returns:

Type Description
Alignment

Alignment asset with recalibrated BAM file; its index is stored as an

Alignment

AlignmentIndex

Reference

https://gatk.broadinstitute.org/hc/en-us/articles/360037055712-ApplyBQSR

Source code in src/stargazer/tasks/gatk/apply_bqsr.py
@gatk_env.task
async def apply_bqsr(
    alignment: Alignment,
    ref: Reference,
    bqsr_report: BQSRReport,
) -> Alignment:
    """
    Apply Base Quality Score Recalibration to a BAM file.

    Args:
        alignment: Input BAM asset
        ref: Reference FASTA asset
        bqsr_report: Recalibration table from base_recalibrator

    Returns:
        Alignment asset with recalibrated BAM file; its index is stored as an
        AlignmentIndex

    Reference:
        https://gatk.broadinstitute.org/hc/en-us/articles/360037055712-ApplyBQSR
    """
    logger.info(alignment.to_dict())
    logger.info(ref.to_dict())
    logger.info(bqsr_report.to_dict())
    # fetch() auto-downloads companions (.fai, .dict, .bai, etc.)
    bam_path = await alignment.fetch()
    ref_path = await ref.fetch()
    recal_path = await bqsr_report.fetch()

    if not recal_path or not recal_path.exists():
        raise FileNotFoundError("BQSR recalibration report not found in cache.")

    output_dir = _storage.default_client.local_dir
    output_bam = output_dir / f"{alignment.sample_id}_recalibrated.bam"

    cmd = [
        "gatk",
        "ApplyBQSR",
        "-R",
        str(ref_path),
        "-I",
        str(bam_path),
        "--bqsr-recal-file",
        str(recal_path),
        "-O",
        str(output_bam),
    ]

    await _run(cmd, cwd=str(output_dir))

    if not output_bam.exists():
        raise FileNotFoundError(f"ApplyBQSR did not create output BAM at {output_bam}")

    recal_bam = Alignment()
    await recal_bam.update(
        output_bam,
        sample_id=alignment.sample_id,
        sorted="coordinate",
        duplicates_marked=alignment.duplicates_marked,
        bqsr_applied=True,
        tool="gatk_apply_bqsr",
        reference_cid=ref.cid,
    )

    # GATK indexes its BAM output by default, naming it <name>.bai.
    bam_index = output_bam.with_suffix(".bai")
    if not bam_index.exists():
        raise FileNotFoundError(f"ApplyBQSR did not create {bam_index}")
    idx = AlignmentIndex()
    await idx.update(
        bam_index,
        sample_id=alignment.sample_id,
        companion_of=recal_bam.cid,
    )

    logger.info(recal_bam.to_dict())
    return recal_bam

ApplyVQSR task for Stargazer.

Applies VQSR recalibration to a VCF using GATK ApplyVQSR.

spec: docs/architecture/tasks.md

apply_vqsr(vcf, ref, vqsr_model, truth_sensitivity_filter_level=None) async

Apply VQSR recalibration to a VCF using GATK ApplyVQSR.

The recalibration mode (SNP or INDEL) is read from vqsr_model.mode. The recal file's index and the tranches file are the model's stored companions. If truth_sensitivity_filter_level is not provided, defaults to 99.5 for SNP and 99.0 for INDEL.

Parameters:

Name Type Description Default
vcf Variants

Raw (or SNP-filtered) VCF Variants asset

required
ref Reference

Reference FASTA asset

required
vqsr_model VQSRModel

Recalibration model from variant_recalibrator

required
truth_sensitivity_filter_level float | None

VQSLOD filter threshold (optional)

None

Returns:

Type Description
Variants

Variants asset with VQSR-filtered VCF

Reference

https://gatk.broadinstitute.org/hc/en-us/articles/360035531612-Variant-Quality-Score-Recalibration-VQSR

Source code in src/stargazer/tasks/gatk/apply_vqsr.py
@gatk_env.task
async def apply_vqsr(
    vcf: Variants,
    ref: Reference,
    vqsr_model: VQSRModel,
    truth_sensitivity_filter_level: float | None = None,
) -> Variants:
    """
    Apply VQSR recalibration to a VCF using GATK ApplyVQSR.

    The recalibration mode (SNP or INDEL) is read from `vqsr_model.mode`.
    The recal file's index and the tranches file are the model's stored
    companions. If truth_sensitivity_filter_level is not provided, defaults
    to 99.5 for SNP and 99.0 for INDEL.

    Args:
        vcf: Raw (or SNP-filtered) VCF Variants asset
        ref: Reference FASTA asset
        vqsr_model: Recalibration model from variant_recalibrator
        truth_sensitivity_filter_level: VQSLOD filter threshold (optional)

    Returns:
        Variants asset with VQSR-filtered VCF

    Reference:
        https://gatk.broadinstitute.org/hc/en-us/articles/360035531612-Variant-Quality-Score-Recalibration-VQSR
    """
    logger.info(vcf.to_dict())
    logger.info(ref.to_dict())
    logger.info(vqsr_model.to_dict())
    mode = vqsr_model.mode or "SNP"
    if mode not in ("SNP", "INDEL"):
        raise ValueError(f"VQSRModel mode must be 'SNP' or 'INDEL', got {mode!r}")

    filter_level = truth_sensitivity_filter_level or _DEFAULT_FILTER_LEVEL[mode]

    vcf_path = await vcf.fetch()
    ref_path = await ref.fetch()
    model_path = await vqsr_model.fetch()
    tranches = await assemble(asset="vqsr_tranches", companion_of=vqsr_model.cid)
    if not tranches:
        raise ValueError(f"VQSRModel {vqsr_model.cid} has no stored tranches file")
    tranches_path = await tranches[0].fetch()

    output_dir = _storage.default_client.local_dir
    sample_id = vcf.sample_id or "cohort"
    output_vcf = output_dir / f"{sample_id}_vqsr_{mode.lower()}.vcf"

    cmd = [
        "gatk",
        "ApplyVQSR",
        "-R",
        str(ref_path),
        "-V",
        str(vcf_path),
        "--recal-file",
        str(model_path),
        "--tranches-file",
        str(tranches_path),
        "--truth-sensitivity-filter-level",
        str(filter_level),
        "--create-output-variant-index",
        "true",
        "-mode",
        mode,
        "-O",
        str(output_vcf),
    ]

    _, stderr = await _run(cmd, cwd=str(output_dir))

    if not output_vcf.exists():
        raise FileNotFoundError(
            f"ApplyVQSR did not create output VCF at {output_vcf}. stderr: {stderr}"
        )

    source_samples = vcf.source_samples or [sample_id]
    filtered_vcf = Variants()
    await filtered_vcf.update(
        output_vcf,
        sample_id=sample_id,
        caller="apply_vqsr",
        variant_type="vcf",
        vqsr_mode=mode,
        build=ref.build,
        sample_count=len(source_samples),
        source_samples=source_samples,
    )

    idx_path = output_dir / f"{output_vcf.name}.idx"
    if idx_path.exists():
        vidx = VariantsIndex()
        await vidx.update(idx_path, sample_id=sample_id, companion_of=filtered_vcf.cid)

    logger.info(filtered_vcf.to_dict())
    return filtered_vcf

base_recalibrator task for Stargazer.

Creates BQSR recalibration table using GATK BaseRecalibrator.

spec: docs/architecture/tasks.md

base_recalibrator(alignment, ref, known_sites) async

Generate a Base Quality Score Recalibration report.

Uses GATK BaseRecalibrator to analyze patterns of covariation in the sequence dataset and produce a recalibration table.

Parameters:

Name Type Description Default
alignment Alignment

Input BAM asset (should be sorted and have duplicates marked)

required
ref Reference

Reference FASTA asset

required
known_sites list[KnownSites]

List of KnownSites VCF assets (dbSNP, known indels, etc.)

required

Returns:

Type Description
BQSRReport

BQSRReport asset containing the recalibration table

Reference

https://gatk.broadinstitute.org/hc/en-us/articles/360036898312-BaseRecalibrator

Source code in src/stargazer/tasks/gatk/base_recalibrator.py
@gatk_env.task
async def base_recalibrator(
    alignment: Alignment,
    ref: Reference,
    known_sites: list[KnownSites],
) -> BQSRReport:
    """
    Generate a Base Quality Score Recalibration report.

    Uses GATK BaseRecalibrator to analyze patterns of covariation in the
    sequence dataset and produce a recalibration table.

    Args:
        alignment: Input BAM asset (should be sorted and have duplicates marked)
        ref: Reference FASTA asset
        known_sites: List of KnownSites VCF assets (dbSNP, known indels, etc.)

    Returns:
        BQSRReport asset containing the recalibration table

    Reference:
        https://gatk.broadinstitute.org/hc/en-us/articles/360036898312-BaseRecalibrator
    """
    logger.info(alignment.to_dict())
    logger.info(ref.to_dict())
    logger.info([x.to_dict() for x in known_sites])
    if not known_sites:
        raise ValueError("known_sites list cannot be empty for BQSR")

    # fetch() auto-downloads companions (.fai, .dict, .bai, etc.)
    bam_path = await alignment.fetch()
    ref_path = await ref.fetch()
    site_paths = [await site.fetch() for site in known_sites]

    output_dir = _storage.default_client.local_dir
    output_recal = output_dir / f"{alignment.sample_id}_bqsr.table"

    cmd = [
        "gatk",
        "BaseRecalibrator",
        "-R",
        str(ref_path),
        "-I",
        str(bam_path),
        "-O",
        str(output_recal),
    ]
    for site_path in site_paths:
        cmd.extend(["--known-sites", str(site_path)])

    await _run(cmd, cwd=str(output_dir))

    if not output_recal.exists():
        raise FileNotFoundError(
            f"BaseRecalibrator did not create recalibration report at {output_recal}"
        )

    report = BQSRReport()
    await report.update(
        output_recal,
        sample_id=alignment.sample_id,
        tool="gatk_base_recalibrator",
        alignment_cid=alignment.cid,
    )

    logger.info(report.to_dict())
    return report

CombineGVCFs task for Stargazer.

Combines multiple per-sample GVCFs into a single multi-sample GVCF for joint genotyping using GATK CombineGVCFs.

spec: docs/architecture/tasks.md

combine_gvcfs(gvcfs, ref, cohort_id='cohort') async

Combine multiple per-sample GVCFs into a single multi-sample GVCF.

Parameters:

Name Type Description Default
gvcfs list[Variants]

List of Variants assets, each containing a GVCF from a single sample

required
ref Reference

Reference FASTA asset

required
cohort_id str

Identifier for the combined cohort (default: "cohort")

'cohort'

Returns:

Type Description
Variants

Variants asset with combined multi-sample GVCF

Reference

https://gatk.broadinstitute.org/hc/en-us/articles/360037053272-CombineGVCFs

Source code in src/stargazer/tasks/gatk/combine_gvcfs.py
@gatk_env.task
async def combine_gvcfs(
    gvcfs: list[Variants],
    ref: Reference,
    cohort_id: str = "cohort",
) -> Variants:
    """
    Combine multiple per-sample GVCFs into a single multi-sample GVCF.

    Args:
        gvcfs: List of Variants assets, each containing a GVCF from a single sample
        ref: Reference FASTA asset
        cohort_id: Identifier for the combined cohort (default: "cohort")

    Returns:
        Variants asset with combined multi-sample GVCF

    Reference:
        https://gatk.broadinstitute.org/hc/en-us/articles/360037053272-CombineGVCFs
    """
    logger.info([x.to_dict() for x in gvcfs])
    logger.info(ref.to_dict())
    if not gvcfs:
        raise ValueError("gvcfs list cannot be empty")

    for i, gvcf in enumerate(gvcfs):
        if gvcf.variant_type != "gvcf":
            raise ValueError(
                f"combine_gvcfs requires GVCF files, but gvcfs[{i}] is not a GVCF. "
                f"sample_id={gvcf.sample_id}"
            )

    # fetch() auto-downloads companions (.fai, .dict for ref; .idx for each gvcf)
    ref_path = await ref.fetch()

    gvcf_paths: list[Path] = []
    sample_ids: list[str] = []

    for gvcf in gvcfs:
        gvcf_paths.append(await gvcf.fetch())
        sample_ids.append(gvcf.sample_id)

    output_dir = _storage.default_client.local_dir
    output_gvcf = output_dir / f"{cohort_id}_combined.g.vcf"

    cmd = [
        "gatk",
        "CombineGVCFs",
        "-R",
        str(ref_path),
        "-O",
        str(output_gvcf),
    ]

    for gvcf_path in gvcf_paths:
        cmd.extend(["-V", str(gvcf_path)])

    _stdout, stderr = await _run(cmd, cwd=str(output_dir))

    if not output_gvcf.exists():
        raise FileNotFoundError(
            f"CombineGVCFs did not create output GVCF at {output_gvcf}. "
            f"stderr: {stderr}"
        )

    combined_vcf = Variants()
    await combined_vcf.update(
        output_gvcf,
        sample_id=cohort_id,
        caller="combine_gvcfs",
        variant_type="gvcf",
        build=ref.build,
        sample_count=len(sample_ids),
        source_samples=sample_ids,
    )

    # GATK writes the GVCF's index alongside it without being asked.
    idx_path = output_dir / f"{output_gvcf.name}.idx"
    if idx_path.exists():
        vidx = VariantsIndex()
        await vidx.update(idx_path, sample_id=cohort_id, companion_of=combined_vcf.cid)

    logger.info(combined_vcf.to_dict())
    return combined_vcf

GATK CreateSequenceDictionary task for reference genome.

spec: docs/architecture/tasks.md

create_sequence_dictionary(ref) async

Create a sequence dictionary (.dict file) using GATK CreateSequenceDictionary.

Parameters:

Name Type Description Default
ref Reference

Reference FASTA asset

required

Returns:

Type Description
SequenceDict

SequenceDict asset containing the .dict file

Source code in src/stargazer/tasks/gatk/create_sequence_dictionary.py
@gatk_env.task
async def create_sequence_dictionary(ref: Reference) -> SequenceDict:
    """
    Create a sequence dictionary (.dict file) using GATK CreateSequenceDictionary.

    Args:
        ref: Reference FASTA asset

    Returns:
        SequenceDict asset containing the .dict file
    """
    logger.info(ref.to_dict())
    ref_path = await ref.fetch()

    if not ref_path or not ref_path.exists():
        raise FileNotFoundError(f"Reference file not found at {ref_path}")

    output_dir = _storage.default_client.local_dir
    dict_path = output_dir / f"{ref_path.stem}.dict"
    dict_path.unlink(missing_ok=True)
    cmd = [
        "gatk",
        "CreateSequenceDictionary",
        "-R",
        str(ref_path),
        "-O",
        str(dict_path),
    ]
    await _run(cmd, cwd=str(output_dir))

    if not dict_path.exists():
        raise FileNotFoundError(
            f"Sequence dictionary file {dict_path.name} was not created"
        )

    seq_dict = SequenceDict()
    await seq_dict.update(
        dict_path,
        build=ref.build,
        tool="gatk_CreateSequenceDictionary",
        companion_of=ref.cid,
    )

    logger.info(seq_dict.to_dict())
    return seq_dict

haplotype_caller task for Stargazer.

Calls germline SNPs and indels via local re-assembly of haplotypes using GATK HaplotypeCaller in GVCF mode.

spec: docs/architecture/tasks.md

haplotype_caller(alignment, ref) async

Call germline variants in GVCF mode using GATK HaplotypeCaller.

Parameters:

Name Type Description Default
alignment Alignment

Sorted, duplicate-marked BAM asset (BQSR-recalibrated recommended)

required
ref Reference

Reference FASTA asset with sequence dictionary

required

Returns:

Type Description
Variants

Variants asset containing the per-sample GVCF

Reference

https://gatk.broadinstitute.org/hc/en-us/articles/360037225632-HaplotypeCaller

Source code in src/stargazer/tasks/gatk/haplotype_caller.py
@gatk_env.task
async def haplotype_caller(
    alignment: Alignment,
    ref: Reference,
) -> Variants:
    """
    Call germline variants in GVCF mode using GATK HaplotypeCaller.

    Args:
        alignment: Sorted, duplicate-marked BAM asset (BQSR-recalibrated recommended)
        ref: Reference FASTA asset with sequence dictionary

    Returns:
        Variants asset containing the per-sample GVCF

    Reference:
        https://gatk.broadinstitute.org/hc/en-us/articles/360037225632-HaplotypeCaller
    """
    logger.info(alignment.to_dict())
    logger.info(ref.to_dict())
    # fetch() auto-downloads companions (.fai, .dict, .bai)
    bam_path = await alignment.fetch()
    ref_path = await ref.fetch()

    output_dir = _storage.default_client.local_dir
    output_gvcf = output_dir / f"{alignment.sample_id}.g.vcf"

    cmd = [
        "gatk",
        "HaplotypeCaller",
        "-R",
        str(ref_path),
        "-I",
        str(bam_path),
        "-O",
        str(output_gvcf),
        "--emit-ref-confidence",
        "GVCF",
    ]

    await _run(cmd, cwd=str(output_dir))

    if not output_gvcf.exists():
        raise FileNotFoundError(
            f"HaplotypeCaller did not create output GVCF at {output_gvcf}"
        )

    gvcf = Variants()
    await gvcf.update(
        output_gvcf,
        sample_id=alignment.sample_id,
        caller="haplotype_caller",
        variant_type="gvcf",
        build=ref.build,
        sample_count=1,
        source_samples=[alignment.sample_id],
    )

    # GATK writes the GVCF's index alongside it without being asked.
    idx_path = output_dir / f"{output_gvcf.name}.idx"
    if idx_path.exists():
        vidx = VariantsIndex()
        await vidx.update(
            idx_path, sample_id=alignment.sample_id, companion_of=gvcf.cid
        )

    logger.info(gvcf.to_dict())
    return gvcf

GATK IndexFeatureFile task for indexing VCF files.

spec: docs/architecture/tasks.md

index_feature_file(known_sites) async

Index a VCF file using GATK IndexFeatureFile.

Required by tools like BaseRecalibrator that need random-access queries over known sites VCFs.

Parameters:

Name Type Description Default
known_sites KnownSites

KnownSites VCF asset to index

required

Returns:

Type Description
KnownSitesIndex

KnownSitesIndex asset pointing to the generated .idx file

Source code in src/stargazer/tasks/gatk/index_feature_file.py
@gatk_env.task
async def index_feature_file(known_sites: KnownSites) -> KnownSitesIndex:
    """
    Index a VCF file using GATK IndexFeatureFile.

    Required by tools like BaseRecalibrator that need random-access queries
    over known sites VCFs.

    Args:
        known_sites: KnownSites VCF asset to index

    Returns:
        KnownSitesIndex asset pointing to the generated .idx file
    """
    logger.info(known_sites.to_dict())
    vcf_path = await known_sites.fetch()

    output_dir = _storage.default_client.local_dir
    idx_path = vcf_path.with_suffix(vcf_path.suffix + ".idx")

    if not idx_path.exists():
        await _run(
            ["gatk", "IndexFeatureFile", "-I", str(vcf_path)],
            cwd=str(output_dir),
        )

    if not idx_path.exists():
        raise FileNotFoundError(f"IndexFeatureFile did not create index at {idx_path}")

    index = KnownSitesIndex()
    await index.update(idx_path, companion_of=known_sites.cid)

    logger.info(index.to_dict())
    return index

joint_call_gvcfs task for Stargazer.

Consolidates per-sample GVCFs into a GenomicsDB datastore and performs joint genotyping in a single task, avoiding the need to persist the GenomicsDB workspace between tasks.

spec: docs/architecture/tasks.md

joint_call_gvcfs(gvcfs, ref, intervals=None, cohort_id='cohort') async

Consolidate GVCFs into GenomicsDB and joint-genotype in a single task.

Runs GenomicsDBImport followed immediately by GenotypeGVCFs within the same execution context, so the workspace never needs to leave the pod.

Parameters:

Name Type Description Default
gvcfs list[Variants]

Per-sample GVCF Variants assets from HaplotypeCaller

required
ref Reference

Reference FASTA asset

required
intervals list[str] | None

Genomic intervals to process (GenomicsDBImport needs at least one); defaults to every contig in the reference

None
cohort_id str

Sample ID label for the output VCF (default: "cohort")

'cohort'

Returns:

Type Description
Variants

Joint-genotyped Variants asset (VCF)

Reference

https://gatk.broadinstitute.org/hc/en-us/articles/360035535932

Source code in src/stargazer/tasks/gatk/joint_call_gvcfs.py
@gatk_env.task
async def joint_call_gvcfs(
    gvcfs: list[Variants],
    ref: Reference,
    intervals: list[str] | None = None,
    cohort_id: str = "cohort",
) -> Variants:
    """
    Consolidate GVCFs into GenomicsDB and joint-genotype in a single task.

    Runs GenomicsDBImport followed immediately by GenotypeGVCFs within the same
    execution context, so the workspace never needs to leave the pod.

    Args:
        gvcfs: Per-sample GVCF Variants assets from HaplotypeCaller
        ref: Reference FASTA asset
        intervals: Genomic intervals to process (GenomicsDBImport needs at
            least one); defaults to every contig in the reference
        cohort_id: Sample ID label for the output VCF (default: "cohort")

    Returns:
        Joint-genotyped Variants asset (VCF)

    Reference:
        https://gatk.broadinstitute.org/hc/en-us/articles/360035535932
    """
    logger.info([x.to_dict() for x in gvcfs])
    logger.info(ref.to_dict())
    if not gvcfs:
        raise ValueError("At least one GVCF must be provided")
    for gvcf in gvcfs:
        if gvcf.variant_type != "gvcf":
            raise ValueError(
                f"All inputs must be GVCFs, got variant_type={gvcf.variant_type!r}: "
                f"sample_id={gvcf.sample_id}"
            )

    ref_path = await ref.fetch()
    if intervals is None:
        intervals = await ref.contigs()
    gvcf_paths = [await gvcf.fetch() for gvcf in gvcfs]

    output_dir = _storage.default_client.local_dir

    with tempfile.TemporaryDirectory() as tmpdir:
        workspace = Path(tmpdir) / f"{cohort_id}_genomicsdb"

        sample_map = Path(tmpdir) / "sample_map.txt"
        with open(sample_map, "w") as f:
            for gvcf, gvcf_path in zip(gvcfs, gvcf_paths, strict=True):
                f.write(f"{gvcf.sample_id}\t{gvcf_path}\n")

        import_cmd = [
            "gatk",
            "GenomicsDBImport",
            "--genomicsdb-workspace-path",
            str(workspace),
            "--sample-name-map",
            str(sample_map),
        ]
        for interval in intervals:
            import_cmd.extend(["-L", interval])

        await _run(import_cmd, cwd=tmpdir)

        if not workspace.exists():
            raise FileNotFoundError(
                f"GenomicsDBImport did not create workspace at {workspace}"
            )

        output_vcf = output_dir / f"{cohort_id}_genotyped.vcf"
        genotype_cmd = [
            "gatk",
            "GenotypeGVCFs",
            "-R",
            str(ref_path),
            "-V",
            f"gendb://{workspace}",
            "-O",
            str(output_vcf),
        ]

        _, stderr = await _run(genotype_cmd, cwd=tmpdir)

    if not output_vcf.exists():
        raise FileNotFoundError(
            f"GenotypeGVCFs did not create output VCF at {output_vcf}. stderr: {stderr}"
        )

    source_samples = [g.sample_id for g in gvcfs]
    vcf = Variants()
    await vcf.update(
        output_vcf,
        sample_id=cohort_id,
        caller="joint_call_gvcfs",
        variant_type="vcf",
        build=ref.build,
        sample_count=len(source_samples),
        source_samples=source_samples,
    )

    idx_path = output_dir / f"{output_vcf.name}.idx"
    if idx_path.exists():
        vidx = VariantsIndex()
        await vidx.update(idx_path, sample_id=cohort_id, companion_of=vcf.cid)

    logger.info(vcf.to_dict())
    return vcf

mark_duplicates task for Stargazer.

Marks duplicate reads in BAM files using GATK MarkDuplicates.

spec: docs/architecture/tasks.md

mark_duplicates(alignment) async

Mark duplicate reads in a BAM file.

Uses GATK MarkDuplicates to identify and tag duplicate reads that originated from the same DNA fragment (PCR or optical duplicates). Duplicates are marked with the 0x0400 SAM flag.

Parameters:

Name Type Description Default
alignment Alignment

Input BAM asset (should be coordinate sorted)

required

Returns:

Type Description
Alignment

Alignment asset with duplicates marked

Reference

https://gatk.broadinstitute.org/hc/en-us/articles/360037052812-MarkDuplicates-Picard

Source code in src/stargazer/tasks/gatk/mark_duplicates.py
@gatk_env.task
async def mark_duplicates(alignment: Alignment) -> Alignment:
    """
    Mark duplicate reads in a BAM file.

    Uses GATK MarkDuplicates to identify and tag duplicate reads that
    originated from the same DNA fragment (PCR or optical duplicates).
    Duplicates are marked with the 0x0400 SAM flag.

    Args:
        alignment: Input BAM asset (should be coordinate sorted)

    Returns:
        Alignment asset with duplicates marked

    Reference:
        https://gatk.broadinstitute.org/hc/en-us/articles/360037052812-MarkDuplicates-Picard
    """
    logger.info(alignment.to_dict())
    bam_path = await alignment.fetch()
    output_dir = _storage.default_client.local_dir

    output_bam = output_dir / f"{alignment.sample_id}_marked_duplicates.bam"
    metrics_file = output_dir / f"{alignment.sample_id}_duplicate_metrics.txt"

    cmd = [
        "gatk",
        "MarkDuplicates",
        "-I",
        str(bam_path),
        "-O",
        str(output_bam),
        "-M",
        str(metrics_file),
        "--CREATE_INDEX",
        "true",
    ]

    await _run(cmd, cwd=str(output_dir))

    if not output_bam.exists():
        raise FileNotFoundError(
            f"MarkDuplicates did not create output BAM at {output_bam}"
        )

    marked_bam = Alignment()
    await marked_bam.update(
        output_bam,
        sample_id=alignment.sample_id,
        sorted="coordinate",
        duplicates_marked=True,
        bqsr_applied=alignment.bqsr_applied,
        tool="gatk_mark_duplicates",
    )

    # Picard names the index <name>.bai, not <name>.bam.bai.
    bam_index = output_bam.with_suffix(".bai")
    if not bam_index.exists():
        raise FileNotFoundError(f"--CREATE_INDEX did not create {bam_index}")
    idx = AlignmentIndex()
    await idx.update(
        bam_index,
        sample_id=alignment.sample_id,
        companion_of=marked_bam.cid,
    )

    if metrics_file.exists():
        metrics = DuplicateMetrics()
        await metrics.update(
            metrics_file,
            sample_id=alignment.sample_id,
            tool="gatk_mark_duplicates",
            alignment_cid=marked_bam.cid,
        )

    logger.info(marked_bam.to_dict())
    return marked_bam

merge_bam_alignment task for Stargazer.

Merges aligned BAM with unmapped BAM using GATK MergeBamAlignment.

spec: docs/architecture/tasks.md

merge_bam_alignment(aligned_bam, unmapped_bam, ref) async

Merge alignment data from aligned BAM with data in unmapped BAM.

Parameters:

Name Type Description Default
aligned_bam Alignment

Aligned BAM asset from aligner

required
unmapped_bam Alignment

Original unmapped BAM asset (must be queryname sorted)

required
ref Reference

Reference FASTA asset

required

Returns:

Type Description
Alignment

Alignment asset with merged BAM file

Reference

https://gatk.broadinstitute.org/hc/en-us/articles/360037226472-MergeBamAlignment-Picard

Source code in src/stargazer/tasks/gatk/merge_bam_alignment.py
@gatk_env.task
async def merge_bam_alignment(
    aligned_bam: Alignment,
    unmapped_bam: Alignment,
    ref: Reference,
) -> Alignment:
    """
    Merge alignment data from aligned BAM with data in unmapped BAM.

    Args:
        aligned_bam: Aligned BAM asset from aligner
        unmapped_bam: Original unmapped BAM asset (must be queryname sorted)
        ref: Reference FASTA asset

    Returns:
        Alignment asset with merged BAM file

    Reference:
        https://gatk.broadinstitute.org/hc/en-us/articles/360037226472-MergeBamAlignment-Picard
    """
    logger.info(aligned_bam.to_dict())
    logger.info(unmapped_bam.to_dict())
    logger.info(ref.to_dict())
    # fetch() auto-downloads companions (.fai, .dict for ref)
    aligned_path = await aligned_bam.fetch()
    unmapped_path = await unmapped_bam.fetch()
    ref_path = await ref.fetch()

    output_dir = _storage.default_client.local_dir
    output_bam = output_dir / f"{aligned_bam.sample_id}_merged.bam"

    cmd = [
        "gatk",
        "MergeBamAlignment",
        "-R",
        str(ref_path),
        "-ALIGNED",
        str(aligned_path),
        "-UNMAPPED",
        str(unmapped_path),
        "-O",
        str(output_bam),
        "--SORT_ORDER",
        "coordinate",
        "--CREATE_INDEX",
        "true",
        "--ADD_MATE_CIGAR",
        "true",
        "--CLIP_ADAPTERS",
        "true",
        "--CLIP_OVERLAPPING_READS",
        "true",
        "--INCLUDE_SECONDARY_ALIGNMENTS",
        "true",
        "--MAX_INSERTIONS_OR_DELETIONS",
        "-1",
    ]

    await _run(cmd, cwd=str(output_dir))

    if not output_bam.exists():
        raise FileNotFoundError(
            f"MergeBamAlignment did not create output BAM at {output_bam}"
        )

    merged_bam = Alignment()
    await merged_bam.update(
        output_bam,
        sample_id=aligned_bam.sample_id,
        sorted="coordinate",
        duplicates_marked=aligned_bam.duplicates_marked,
        bqsr_applied=aligned_bam.bqsr_applied,
        tool="gatk_merge_bam_alignment",
    )

    # Picard names the index <name>.bai, not <name>.bam.bai.
    bam_index = output_bam.with_suffix(".bai")
    if not bam_index.exists():
        raise FileNotFoundError(f"--CREATE_INDEX did not create {bam_index}")
    idx = AlignmentIndex()
    await idx.update(
        bam_index,
        sample_id=aligned_bam.sample_id,
        companion_of=merged_bam.cid,
    )

    logger.info(merged_bam.to_dict())
    return merged_bam

sort_sam task for Stargazer.

Sorts BAM files using GATK SortSam.

spec: docs/architecture/tasks.md

sort_sam(alignment, sort_order='coordinate') async

Sort a SAM/BAM file.

Parameters:

Name Type Description Default
alignment Alignment

Input BAM asset to sort

required
sort_order str

Sort order - one of "coordinate", "queryname", "duplicate"

'coordinate'

Returns:

Type Description
Alignment

Alignment asset with sorted BAM file

Reference

https://gatk.broadinstitute.org/hc/en-us/articles/360037056932-SortSam-Picard

Source code in src/stargazer/tasks/gatk/sort_sam.py
@gatk_env.task
async def sort_sam(
    alignment: Alignment,
    sort_order: str = "coordinate",
) -> Alignment:
    """
    Sort a SAM/BAM file.

    Args:
        alignment: Input BAM asset to sort
        sort_order: Sort order - one of "coordinate", "queryname", "duplicate"

    Returns:
        Alignment asset with sorted BAM file

    Reference:
        https://gatk.broadinstitute.org/hc/en-us/articles/360037056932-SortSam-Picard
    """
    logger.info(alignment.to_dict())
    valid_sort_orders = ["coordinate", "queryname", "duplicate"]
    if sort_order not in valid_sort_orders:
        raise ValueError(
            f"Invalid sort_order: {sort_order}. Must be one of {valid_sort_orders}"
        )

    bam_path = await alignment.fetch()
    output_dir = _storage.default_client.local_dir
    output_bam = output_dir / f"{alignment.sample_id}_sorted_{sort_order}.bam"

    cmd = [
        "gatk",
        "SortSam",
        "-I",
        str(bam_path),
        "-O",
        str(output_bam),
        "--SORT_ORDER",
        sort_order,
    ]

    if sort_order == "coordinate":
        cmd.extend(["--CREATE_INDEX", "true"])

    await _run(cmd, cwd=str(output_dir))

    if not output_bam.exists():
        raise FileNotFoundError(f"SortSam did not create output BAM at {output_bam}")

    sorted_bam = Alignment()
    await sorted_bam.update(
        output_bam,
        sample_id=alignment.sample_id,
        sorted=sort_order,
        duplicates_marked=alignment.duplicates_marked,
        bqsr_applied=alignment.bqsr_applied,
        tool="gatk_sort_sam",
    )

    if sort_order == "coordinate":
        # Picard names the index <name>.bai, not <name>.bam.bai.
        bam_index = output_bam.with_suffix(".bai")
        if not bam_index.exists():
            raise FileNotFoundError(f"--CREATE_INDEX did not create {bam_index}")
        idx = AlignmentIndex()
        await idx.update(
            bam_index,
            sample_id=alignment.sample_id,
            companion_of=sorted_bam.cid,
        )

    logger.info(sorted_bam.to_dict())
    return sorted_bam

VariantRecalibrator task for Stargazer.

Builds a recalibration model for VQSR using GATK VariantRecalibrator.

spec: docs/architecture/tasks.md

variant_recalibrator(vcf, ref, resources, mode='SNP') async

Build a VQSR recalibration model using GATK VariantRecalibrator.

Each KnownSites in resources sets these fields: resource_name: e.g. "hapmap", "omni", "1000G", "dbsnp", "mills" known: "true" or "false" training: "true" or "false" truth: "true" or "false" prior: numeric string, e.g. "15"

Parameters:

Name Type Description Default
vcf Variants

Raw genotyped VCF Variants asset

required
ref Reference

Reference FASTA asset

required
resources list[KnownSites]

Training/truth VCF resources for the recalibrator

required
mode str

Variant type to recalibrate — "SNP" or "INDEL"

'SNP'

Returns:

Type Description
VQSRModel

VQSRModel asset (the recal file). Its index and the tranches file

VQSRModel

are stored as its companions, so apply_vqsr in any pod gets them.

Reference

https://gatk.broadinstitute.org/hc/en-us/articles/360035531612-Variant-Quality-Score-Recalibration-VQSR

Source code in src/stargazer/tasks/gatk/variant_recalibrator.py
@gatk_env.task
async def variant_recalibrator(
    vcf: Variants,
    ref: Reference,
    resources: list[KnownSites],
    mode: str = "SNP",
) -> VQSRModel:
    """
    Build a VQSR recalibration model using GATK VariantRecalibrator.

    Each KnownSites in ``resources`` sets these fields:
        resource_name: e.g. "hapmap", "omni", "1000G", "dbsnp", "mills"
        known:         "true" or "false"
        training:      "true" or "false"
        truth:         "true" or "false"
        prior:         numeric string, e.g. "15"

    Args:
        vcf: Raw genotyped VCF Variants asset
        ref: Reference FASTA asset
        resources: Training/truth VCF resources for the recalibrator
        mode: Variant type to recalibrate — "SNP" or "INDEL"

    Returns:
        VQSRModel asset (the recal file). Its index and the tranches file
        are stored as its companions, so `apply_vqsr` in any pod gets them.

    Reference:
        https://gatk.broadinstitute.org/hc/en-us/articles/360035531612-Variant-Quality-Score-Recalibration-VQSR
    """
    logger.info(vcf.to_dict())
    logger.info(ref.to_dict())
    logger.info([x.to_dict() for x in resources])
    if mode not in ("SNP", "INDEL"):
        raise ValueError(f"mode must be 'SNP' or 'INDEL', got {mode!r}")
    if not resources:
        raise ValueError(f"At least one resource VCF is required for mode={mode!r}")

    vcf_path = await vcf.fetch()
    ref_path = await ref.fetch()
    resource_paths = [await r.fetch() for r in resources]

    output_dir = _storage.default_client.local_dir
    sample_id = vcf.sample_id or "cohort"
    output_recal = output_dir / f"{sample_id}_{mode.lower()}.recal"
    output_tranches = output_dir / f"{sample_id}_{mode.lower()}.tranches"

    annotations = _SNP_ANNOTATIONS if mode == "SNP" else _INDEL_ANNOTATIONS

    cmd = [
        "gatk",
        "VariantRecalibrator",
        "-R",
        str(ref_path),
        "-V",
        str(vcf_path),
        "-mode",
        mode,
        "-O",
        str(output_recal),
        "--tranches-file",
        str(output_tranches),
    ]

    for r, r_path in zip(resources, resource_paths, strict=True):
        name = r.resource_name or "unknown"
        known = r.known or "false"
        training = r.training or "false"
        truth = r.truth or "false"
        prior = r.prior or "10"
        cmd.extend(
            [
                f"--resource:{name},known={known},training={training},truth={truth},prior={prior}",
                str(r_path),
            ]
        )

    for ann in annotations:
        cmd.extend(["-an", ann])

    _, stderr = await _run(cmd, cwd=str(output_dir))

    if not output_recal.exists():
        raise FileNotFoundError(
            f"VariantRecalibrator did not create recal file at {output_recal}. stderr: {stderr}"
        )

    model = VQSRModel()
    await model.update(
        output_recal,
        sample_id=sample_id,
        mode=mode,
        build=ref.build,
        variants_cid=vcf.cid,
    )
    await VQSRModelIndex().update(
        output_dir / f"{output_recal.name}.idx",
        sample_id=sample_id,
        companion_of=model.cid,
    )
    await VQSRTranches().update(
        output_tranches, sample_id=sample_id, companion_of=model.cid
    )

    logger.info(model.to_dict())
    return model

BWA tasks for reference genome indexing and alignment.

spec: docs/architecture/tasks.md

bwa_index(ref) async

Create BWA index files for a reference genome using bwa index.

Creates the following index files: - .amb, .ann, .bwt, .pac, .sa

Parameters:

Name Type Description Default
ref Reference

Reference FASTA asset

required

Returns:

Type Description
list[AlignerIndex]

List of AlignerIndex assets, one per index file

Reference

https://bio-bwa.sourceforge.net/bwa.shtml

Source code in src/stargazer/tasks/general/bwa.py
@gatk_env.task
async def bwa_index(ref: Reference) -> list[AlignerIndex]:
    """
    Create BWA index files for a reference genome using bwa index.

    Creates the following index files:
    - .amb, .ann, .bwt, .pac, .sa

    Args:
        ref: Reference FASTA asset

    Returns:
        List of AlignerIndex assets, one per index file

    Reference:
        https://bio-bwa.sourceforge.net/bwa.shtml
    """
    logger.info(ref.to_dict())
    ref_path = await ref.fetch()

    if not ref_path or not ref_path.exists():
        raise FileNotFoundError(f"Reference file not found at {ref_path}")

    index_extensions = [".amb", ".ann", ".bwt", ".pac", ".sa"]
    output_dir = _storage.default_client.local_dir
    base_name = ref_path.name

    prefix = output_dir / base_name
    cmd = ["bwa", "index", "-p", str(prefix), str(ref_path)]
    _stdout, _stderr = await _run(cmd, cwd=str(output_dir))

    indices = []
    for ext in index_extensions:
        index_path = output_dir / f"{base_name}{ext}"
        if not index_path.exists():
            raise FileNotFoundError(f"BWA index file {index_path.name} was not created")

        idx = AlignerIndex()
        await idx.update(
            index_path,
            aligner="bwa",
            build=ref.build,
            companion_of=ref.cid,
        )
        indices.append(idx)

    logger.info([x.to_dict() for x in indices])
    return indices

bwa_mem(ref, r1, r2=None, read_group=None) async

Align FASTQ reads to reference genome using BWA-MEM.

Produces an unsorted BAM file that typically needs to be sorted before downstream processing (e.g., with sort_sam).

Parameters:

Name Type Description Default
ref Reference

Reference FASTA asset

required
r1 R1

R1 FASTQ read asset

required
r2 R2 | None

R2 FASTQ read asset (None for single-end)

None
read_group dict[str, str] | None

Optional read group override (ID, SM, LB, PL, PU)

None

Returns:

Type Description
Alignment

Alignment asset containing the unsorted BAM file

Reference

https://bio-bwa.sourceforge.net/bwa.shtml

Source code in src/stargazer/tasks/general/bwa.py
@gatk_env.task
async def bwa_mem(
    ref: Reference,
    r1: R1,
    r2: R2 | None = None,
    read_group: dict[str, str] | None = None,
) -> Alignment:
    """
    Align FASTQ reads to reference genome using BWA-MEM.

    Produces an unsorted BAM file that typically needs to be sorted
    before downstream processing (e.g., with sort_sam).

    Args:
        ref: Reference FASTA asset
        r1: R1 FASTQ read asset
        r2: R2 FASTQ read asset (None for single-end)
        read_group: Optional read group override (ID, SM, LB, PL, PU)

    Returns:
        Alignment asset containing the unsorted BAM file

    Reference:
        https://bio-bwa.sourceforge.net/bwa.shtml
    """
    logger.info(ref.to_dict())
    logger.info(r1.to_dict())
    if r2:
        logger.info(r2.to_dict())
    # fetch() downloads ref + companions (aligner indices, .fai, .dict)
    ref_path = await ref.fetch()
    r1_path = await r1.fetch()
    r2_path = await r2.fetch() if r2 else None
    sample_id = r1.sample_id

    if read_group:
        rg = {"ID": sample_id, "SM": sample_id}
        rg.update(read_group)
        rg_parts = [f"{k}:{v}" for k, v in rg.items()]
        rg_string = r"@RG\t" + r"\t".join(rg_parts)
    else:
        rg_string = rf"@RG\tID:{sample_id}\tSM:{sample_id}\tLB:lib\tPL:ILLUMINA"

    output_dir = _storage.default_client.local_dir
    output_bam = output_dir / f"{sample_id}_aligned.bam"

    cmd = [
        "bwa",
        "mem",
        "-R",
        rg_string,
        "-t",
        "4",
        str(ref_path),
    ]

    if r2_path:
        cmd.extend([str(r1_path), str(r2_path)])
    else:
        cmd.append(str(r1_path))

    bwa_cmd = " ".join(shlex.quote(str(c)) for c in cmd)
    samtools_cmd = f"samtools view -bS -o {output_bam} -"
    full_cmd = f"{bwa_cmd} | {samtools_cmd}"

    logger.info(f"Running: {full_cmd}")
    proc = await asyncio.create_subprocess_shell(
        full_cmd,
        cwd=str(output_dir),
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    stdout, stderr = await proc.communicate()

    if proc.returncode != 0:
        stderr_text = stderr.decode() if stderr else ""
        stdout_text = stdout.decode() if stdout else ""
        raise RuntimeError(
            f"BWA-MEM failed with return code {proc.returncode}.\n"
            f"stdout: {stdout_text}\nstderr: {stderr_text}"
        )

    if not output_bam.exists():
        raise FileNotFoundError(f"BWA-MEM did not create output BAM at {output_bam}")

    bam = Alignment()
    await bam.update(
        output_bam,
        sample_id=sample_id,
        duplicates_marked=False,
        bqsr_applied=False,
        reference_cid=ref.cid,
        r1_cid=r1.cid,
    )

    logger.info(bam.to_dict())
    return bam

BWA-MEM2 tasks for reference genome indexing and alignment.

spec: docs/architecture/tasks.md

bwa_mem2_index(ref) async

Create BWA-MEM2 index files for a reference genome.

Creates the following index files: - .amb, .ann, .bwt.2bit.64, .pac, .sa

Parameters:

Name Type Description Default
ref Reference

Reference FASTA asset

required

Returns:

Type Description
list[AlignerIndex]

List of AlignerIndex assets, one per index file

Reference

https://github.com/bwa-mem2/bwa-mem2

Source code in src/stargazer/tasks/general/bwa_mem2.py
@gatk_env.task
async def bwa_mem2_index(ref: Reference) -> list[AlignerIndex]:
    """
    Create BWA-MEM2 index files for a reference genome.

    Creates the following index files:
    - .amb, .ann, .bwt.2bit.64, .pac, .sa

    Args:
        ref: Reference FASTA asset

    Returns:
        List of AlignerIndex assets, one per index file

    Reference:
        https://github.com/bwa-mem2/bwa-mem2
    """
    logger.info(ref.to_dict())
    ref_path = await ref.fetch()

    if not ref_path or not ref_path.exists():
        raise FileNotFoundError(f"Reference file not found at {ref_path}")

    index_extensions = [".0123", ".amb", ".ann", ".bwt.2bit.64", ".pac"]
    output_dir = _storage.default_client.local_dir
    base_name = ref_path.name

    prefix = output_dir / base_name
    cmd = ["bwa-mem2", "index", "-p", str(prefix), str(ref_path)]
    await _run(cmd, cwd=str(output_dir))

    indices = []
    for ext in index_extensions:
        index_path = output_dir / f"{base_name}{ext}"
        if not index_path.exists():
            raise FileNotFoundError(
                f"BWA-MEM2 index file {index_path.name} was not created"
            )

        idx = AlignerIndex()
        await idx.update(
            index_path,
            aligner="bwa-mem2",
            build=ref.build,
            companion_of=ref.cid,
        )
        indices.append(idx)

    logger.info([x.to_dict() for x in indices])
    return indices

bwa_mem2_mem(ref, r1, r2=None, read_group=None) async

Align FASTQ reads to reference genome using BWA-MEM2.

Produces an unsorted BAM file that typically needs to be sorted before downstream processing (e.g., with sort_sam).

Parameters:

Name Type Description Default
ref Reference

Reference FASTA asset

required
r1 R1

R1 FASTQ read asset

required
r2 R2 | None

R2 FASTQ read asset (None for single-end)

None
read_group dict[str, str] | None

Optional read group override (ID, SM, LB, PL, PU)

None

Returns:

Type Description
Alignment

Alignment asset containing the unsorted BAM file

Reference

https://github.com/bwa-mem2/bwa-mem2

Source code in src/stargazer/tasks/general/bwa_mem2.py
@gatk_env.task
async def bwa_mem2_mem(
    ref: Reference,
    r1: R1,
    r2: R2 | None = None,
    read_group: dict[str, str] | None = None,
) -> Alignment:
    """
    Align FASTQ reads to reference genome using BWA-MEM2.

    Produces an unsorted BAM file that typically needs to be sorted
    before downstream processing (e.g., with sort_sam).

    Args:
        ref: Reference FASTA asset
        r1: R1 FASTQ read asset
        r2: R2 FASTQ read asset (None for single-end)
        read_group: Optional read group override (ID, SM, LB, PL, PU)

    Returns:
        Alignment asset containing the unsorted BAM file

    Reference:
        https://github.com/bwa-mem2/bwa-mem2
    """
    logger.info(ref.to_dict())
    logger.info(r1.to_dict())
    if r2:
        logger.info(r2.to_dict())

    ref_path = await ref.fetch()
    r1_path = await r1.fetch()
    r2_path = await r2.fetch() if r2 else None
    sample_id = r1.sample_id

    if read_group:
        rg = {"ID": sample_id, "SM": sample_id}
        rg.update(read_group)
        rg_parts = [f"{k}:{v}" for k, v in rg.items()]
        rg_string = r"@RG\t" + r"\t".join(rg_parts)
    else:
        rg_string = rf"@RG\tID:{sample_id}\tSM:{sample_id}\tLB:lib\tPL:ILLUMINA"

    output_dir = _storage.default_client.local_dir
    output_bam = output_dir / f"{sample_id}_aligned.bam"

    cmd = [
        "bwa-mem2",
        "mem",
        "-R",
        rg_string,
        "-t",
        "4",
        str(ref_path),
    ]

    if r2_path:
        cmd.extend([str(r1_path), str(r2_path)])
    else:
        cmd.append(str(r1_path))

    bwa_cmd = " ".join(shlex.quote(str(c)) for c in cmd)
    samtools_cmd = f"samtools view -bS -o {output_bam} -"
    full_cmd = f"{bwa_cmd} | {samtools_cmd}"

    logger.info(f"Running: {full_cmd}")
    proc = await asyncio.create_subprocess_shell(
        full_cmd,
        cwd=str(output_dir),
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    stdout, stderr = await proc.communicate()

    if proc.returncode != 0:
        stderr_text = stderr.decode() if stderr else ""
        stdout_text = stdout.decode() if stdout else ""
        raise RuntimeError(
            f"BWA-MEM2 failed with return code {proc.returncode}.\n"
            f"stdout: {stdout_text}\nstderr: {stderr_text}"
        )

    if not output_bam.exists():
        raise FileNotFoundError(f"BWA-MEM2 did not create output BAM at {output_bam}")

    bam = Alignment()
    await bam.update(
        output_bam,
        sample_id=sample_id,
        duplicates_marked=False,
        bqsr_applied=False,
        reference_cid=ref.cid,
        r1_cid=r1.cid,
    )

    logger.info(bam.to_dict())
    return bam

Samtools tasks for reference genome indexing.

spec: docs/architecture/tasks.md

samtools_faidx(ref) async

Create a FASTA index (.fai file) using samtools faidx.

Parameters:

Name Type Description Default
ref Reference

Reference FASTA asset

required

Returns:

Type Description
ReferenceIndex

ReferenceIndex asset containing the .fai file

Source code in src/stargazer/tasks/general/samtools.py
@gatk_env.task
async def samtools_faidx(ref: Reference) -> ReferenceIndex:
    """
    Create a FASTA index (.fai file) using samtools faidx.

    Args:
        ref: Reference FASTA asset

    Returns:
        ReferenceIndex asset containing the .fai file
    """
    logger.info(ref.to_dict())
    ref_path = await ref.fetch()

    if not ref_path or not ref_path.exists():
        raise FileNotFoundError(f"Reference file not found at {ref_path}")

    output_dir = _storage.default_client.local_dir
    fai_path = output_dir / f"{ref_path.name}.fai"
    cmd = ["samtools", "faidx", str(ref_path), "--fai-idx", str(fai_path)]
    await _run(cmd, cwd=str(output_dir))

    if not fai_path.exists():
        raise FileNotFoundError(f"FASTA index file {fai_path.name} was not created")

    faidx = ReferenceIndex()
    await faidx.update(
        fai_path,
        build=ref.build,
        tool="samtools_faidx",
        companion_of=ref.cid,
    )

    logger.info(faidx.to_dict())
    return faidx

Leiden community detection clustering for scRNA-seq data.

spec: docs/architecture/tasks.md

cluster(adata, resolution=0.5, key_added='leiden') async

Assign cells to clusters using the Leiden algorithm.

Requires a precomputed neighbor graph (.uns["neighbors"]). Cluster labels are stored in .obs[key_added].

Parameters:

Name Type Description Default
adata AnnData

AnnData asset with neighbor graph

required
resolution float

Leiden resolution parameter (higher = more clusters)

0.5
key_added str

.obs column name to store cluster labels

'leiden'

Returns:

Type Description
AnnData

AnnData asset with cluster labels in .obs

Source code in src/stargazer/tasks/scrna/cluster.py
@scrna_env.task
async def cluster(
    adata: AnnData,
    resolution: float = 0.5,
    key_added: str = "leiden",
) -> AnnData:
    """Assign cells to clusters using the Leiden algorithm.

    Requires a precomputed neighbor graph (.uns["neighbors"]).
    Cluster labels are stored in .obs[key_added].

    Args:
        adata: AnnData asset with neighbor graph
        resolution: Leiden resolution parameter (higher = more clusters)
        key_added: .obs column name to store cluster labels

    Returns:
        AnnData asset with cluster labels in .obs
    """
    import scanpy as sc

    logger.info(adata.to_dict())
    ad = sc.read_h5ad(await adata.fetch())

    sc.tl.leiden(
        ad, flavor="igraph", n_iterations=2, resolution=resolution, key_added=key_added
    )

    out_path = _storage.default_client.local_dir / f"{adata.sample_id}_clustered.h5ad"
    ad.write_h5ad(out_path)

    result = AnnData(
        sample_id=adata.sample_id,
        organism=adata.organism,
        source_cid=adata.cid,
    )
    await result.update(out_path, n_obs=ad.n_obs, n_vars=ad.n_vars, stage="clustered")
    logger.info(result.to_dict())
    return result

Marker gene identification via differential expression for scRNA-seq data.

spec: docs/architecture/tasks.md

find_markers(adata, groupby='leiden', method='wilcoxon') async

Identify marker genes for each cluster using differential expression.

Tests the log-normalized expression in .X, which scanpy's fold changes assume; the raw counts in .layers["counts"] would skew them. Results are stored in .uns["rank_genes_groups"].

Parameters:

Name Type Description Default
adata AnnData

Clustered AnnData asset with log-normalized .X

required
groupby str

.obs column to group cells by (cluster labels)

'leiden'
method str

Statistical test method ("wilcoxon", "t-test", etc.)

'wilcoxon'

Returns:

Type Description
AnnData

AnnData asset with ranked marker genes in .uns["rank_genes_groups"]

Source code in src/stargazer/tasks/scrna/find_markers.py
@scrna_env.task
async def find_markers(
    adata: AnnData,
    groupby: str = "leiden",
    method: str = "wilcoxon",
) -> AnnData:
    """Identify marker genes for each cluster using differential expression.

    Tests the log-normalized expression in .X, which scanpy's fold changes
    assume; the raw counts in .layers["counts"] would skew them.
    Results are stored in .uns["rank_genes_groups"].

    Args:
        adata: Clustered AnnData asset with log-normalized .X
        groupby: .obs column to group cells by (cluster labels)
        method: Statistical test method ("wilcoxon", "t-test", etc.)

    Returns:
        AnnData asset with ranked marker genes in .uns["rank_genes_groups"]
    """
    import scanpy as sc

    logger.info(adata.to_dict())
    ad = sc.read_h5ad(await adata.fetch())

    sc.tl.rank_genes_groups(ad, groupby=groupby, method=method)

    out_path = _storage.default_client.local_dir / f"{adata.sample_id}_markers.h5ad"
    ad.write_h5ad(out_path)

    result = AnnData(
        sample_id=adata.sample_id,
        organism=adata.organism,
        source_cid=adata.cid,
    )
    await result.update(out_path, n_obs=ad.n_obs, n_vars=ad.n_vars, stage="annotated")
    logger.info(result.to_dict())
    return result

Normalization and log transformation for scRNA-seq data.

spec: docs/architecture/tasks.md

normalize(adata) async

Normalize counts and apply log1p transformation.

Stores raw counts in .layers["counts"] before normalization so they are available for downstream differential expression analysis.

Parameters:

Name Type Description Default
adata AnnData

QC-filtered AnnData asset

required

Returns:

Type Description
AnnData

Normalized and log-transformed AnnData asset

Source code in src/stargazer/tasks/scrna/normalize.py
@scrna_env.task
async def normalize(adata: AnnData) -> AnnData:
    """Normalize counts and apply log1p transformation.

    Stores raw counts in .layers["counts"] before normalization so they
    are available for downstream differential expression analysis.

    Args:
        adata: QC-filtered AnnData asset

    Returns:
        Normalized and log-transformed AnnData asset
    """
    import scanpy as sc

    logger.info(adata.to_dict())
    ad = sc.read_h5ad(await adata.fetch())

    ad.layers["counts"] = ad.X.copy()
    sc.pp.normalize_total(ad)
    sc.pp.log1p(ad)

    out_path = _storage.default_client.local_dir / f"{adata.sample_id}_normalized.h5ad"
    ad.write_h5ad(out_path)

    result = AnnData(
        sample_id=adata.sample_id,
        organism=adata.organism,
        source_cid=adata.cid,
    )
    await result.update(out_path, n_obs=ad.n_obs, n_vars=ad.n_vars, stage="normalized")
    logger.info(result.to_dict())
    return result

Quality control and cell/gene filtering for scRNA-seq data.

spec: docs/architecture/tasks.md

qc_filter(adata, min_genes=100, min_cells=3, max_pct_mt=20.0, batch_key='') async

Filter low-quality cells and genes from raw scRNA-seq data.

Applies standard QC filters: minimum gene/cell thresholds, mitochondrial gene percentage, and scrublet doublet detection. The mitochondrial, ribosomal and hemoglobin genes are picked by the input asset's organism ("human" or "mouse", any case).

Parameters:

Name Type Description Default
adata AnnData

Raw AnnData asset (.h5ad)

required
min_genes int

Minimum number of genes expressed per cell

100
min_cells int

Minimum number of cells a gene must be expressed in

3
max_pct_mt float

Maximum mitochondrial gene percentage allowed per cell

20.0
batch_key str

Column in .obs to use as batch for scrublet (empty = no batch)

''

Returns:

Type Description
AnnData

Filtered AnnData asset with QC metrics in .obs

Raises:

Type Description
ValueError

If the asset's organism is empty or not a supported one.

Source code in src/stargazer/tasks/scrna/qc_filter.py
@scrna_env.task
async def qc_filter(
    adata: AnnData,
    min_genes: int = 100,
    min_cells: int = 3,
    max_pct_mt: float = 20.0,
    batch_key: str = "",
) -> AnnData:
    """Filter low-quality cells and genes from raw scRNA-seq data.

    Applies standard QC filters: minimum gene/cell thresholds, mitochondrial
    gene percentage, and scrublet doublet detection. The mitochondrial,
    ribosomal and hemoglobin genes are picked by the input asset's
    `organism` ("human" or "mouse", any case).

    Args:
        adata: Raw AnnData asset (.h5ad)
        min_genes: Minimum number of genes expressed per cell
        min_cells: Minimum number of cells a gene must be expressed in
        max_pct_mt: Maximum mitochondrial gene percentage allowed per cell
        batch_key: Column in .obs to use as batch for scrublet (empty = no batch)

    Returns:
        Filtered AnnData asset with QC metrics in .obs

    Raises:
        ValueError: If the asset's `organism` is empty or not a supported one.
    """
    import scanpy as sc

    logger.info(adata.to_dict())
    patterns = GENE_PATTERNS.get(adata.organism.lower())
    if patterns is None:
        raise ValueError(
            f"AnnData {adata.sample_id!r} has organism {adata.organism!r}; "
            f"set its organism field to one of: {', '.join(GENE_PATTERNS)}"
        )
    mt, ribo, hb = patterns
    ad = sc.read_h5ad(await adata.fetch())

    sc.pp.filter_cells(ad, min_genes=min_genes)
    sc.pp.filter_genes(ad, min_cells=min_cells)

    ad.var["mt"] = ad.var_names.str.startswith(mt)
    ad.var["ribo"] = ad.var_names.str.startswith(ribo)
    ad.var["hb"] = ad.var_names.str.contains(hb)

    sc.pp.calculate_qc_metrics(
        ad, qc_vars=["mt", "ribo", "hb"], inplace=True, log1p=True
    )

    sc.pp.scrublet(ad, batch_key=batch_key or None)

    ad = ad[ad.obs["pct_counts_mt"] < max_pct_mt]
    ad = ad[~ad.obs["predicted_doublet"]]

    out_path = _storage.default_client.local_dir / f"{adata.sample_id}_qc_filtered.h5ad"
    ad.write_h5ad(out_path)

    result = AnnData(
        sample_id=adata.sample_id,
        organism=adata.organism,
        source_cid=adata.cid,
    )
    await result.update(out_path, n_obs=ad.n_obs, n_vars=ad.n_vars, stage="qc_filtered")
    logger.info(result.to_dict())
    return result

PCA, neighbor graph, and UMAP dimensionality reduction for scRNA-seq data.

spec: docs/architecture/tasks.md

reduce_dimensions(adata, n_pcs=50, n_neighbors=15) async

Compute PCA, k-nearest neighbor graph, and UMAP embedding.

Operates on the highly variable gene subset to reduce noise. Embeddings are stored in .obsm for downstream clustering and visualization.

Parameters:

Name Type Description Default
adata AnnData

AnnData asset with HVG annotations in .var

required
n_pcs int

Number of principal components to compute

50
n_neighbors int

Number of neighbors for the kNN graph

15

Returns:

Type Description
AnnData

AnnData asset with PCA (.obsm["X_pca"]), neighbors, and UMAP (.obsm["X_umap"])

Source code in src/stargazer/tasks/scrna/reduce_dimensions.py
@scrna_env.task
async def reduce_dimensions(
    adata: AnnData,
    n_pcs: int = 50,
    n_neighbors: int = 15,
) -> AnnData:
    """Compute PCA, k-nearest neighbor graph, and UMAP embedding.

    Operates on the highly variable gene subset to reduce noise. Embeddings
    are stored in .obsm for downstream clustering and visualization.

    Args:
        adata: AnnData asset with HVG annotations in .var
        n_pcs: Number of principal components to compute
        n_neighbors: Number of neighbors for the kNN graph

    Returns:
        AnnData asset with PCA (.obsm["X_pca"]), neighbors, and UMAP (.obsm["X_umap"])
    """
    import scanpy as sc

    logger.info(adata.to_dict())
    ad = sc.read_h5ad(await adata.fetch())

    sc.tl.pca(ad, n_comps=n_pcs)
    sc.pp.neighbors(ad, n_neighbors=n_neighbors, n_pcs=n_pcs)
    sc.tl.umap(ad)

    out_path = _storage.default_client.local_dir / f"{adata.sample_id}_reduced.h5ad"
    ad.write_h5ad(out_path)

    result = AnnData(
        sample_id=adata.sample_id,
        organism=adata.organism,
        source_cid=adata.cid,
    )
    await result.update(out_path, n_obs=ad.n_obs, n_vars=ad.n_vars, stage="reduced")
    logger.info(result.to_dict())
    return result

Highly variable gene selection for scRNA-seq data.

spec: docs/architecture/tasks.md

select_features(adata, n_top_genes=2000, batch_key='') async

Select highly variable genes for dimensionality reduction.

Annotates .var with highly_variable flags. Downstream tasks use only the highly variable subset.

Parameters:

Name Type Description Default
adata AnnData

Normalized AnnData asset

required
n_top_genes int

Number of top highly variable genes to select

2000
batch_key str

Column in .obs to use as batch (empty = no batch correction)

''

Returns:

Type Description
AnnData

AnnData asset with HVG annotations in .var

Source code in src/stargazer/tasks/scrna/select_features.py
@scrna_env.task
async def select_features(
    adata: AnnData,
    n_top_genes: int = 2000,
    batch_key: str = "",
) -> AnnData:
    """Select highly variable genes for dimensionality reduction.

    Annotates .var with highly_variable flags. Downstream tasks use only
    the highly variable subset.

    Args:
        adata: Normalized AnnData asset
        n_top_genes: Number of top highly variable genes to select
        batch_key: Column in .obs to use as batch (empty = no batch correction)

    Returns:
        AnnData asset with HVG annotations in .var
    """
    import scanpy as sc

    logger.info(adata.to_dict())
    ad = sc.read_h5ad(await adata.fetch())

    hvg_kwargs = {"n_top_genes": n_top_genes}
    if batch_key:
        hvg_kwargs["batch_key"] = batch_key
    sc.pp.highly_variable_genes(ad, **hvg_kwargs)

    out_path = (
        _storage.default_client.local_dir / f"{adata.sample_id}_features_selected.h5ad"
    )
    ad.write_h5ad(out_path)

    result = AnnData(
        sample_id=adata.sample_id,
        organism=adata.organism,
        source_cid=adata.cid,
    )
    await result.update(out_path, n_obs=ad.n_obs, n_vars=ad.n_vars, stage="featured")
    logger.info(result.to_dict())
    return result

Utils

IPFS CIDs computed locally.

An asset's identity is the CID Pinata would assign it, worked out without uploading anything. compute_cid() builds the same UnixFS DAG Pinata builds: CIDv1, raw leaves, 256 KiB chunks, a balanced tree of at most 174 links per node, sha2-256 throughout. A file that fits in one chunk is its own raw leaf (bafkrei…); anything larger gets a dag-pb root (bafybei…). CidBuilder works it out from bytes as they arrive, in pieces of any size, and compute_cid() is the builder fed a file.

Those parameters are part of every asset's identity. If Pinata changed its defaults, a newly published file's CID would stop matching the one computed here.

spec: docs/architecture/types.md

CidBuilder

Works out a file's CID from its bytes, fed in pieces of any size.

Each full leaf is hashed as soon as its bytes are in, so the state is the leaves' CIDs and sizes plus at most one partial leaf: a few megabytes for a 10 GB file. The tree over the leaves is built when cid() is asked.

Source code in src/stargazer/utils/cid.py
class CidBuilder:
    """Works out a file's CID from its bytes, fed in pieces of any size.

    Each full leaf is hashed as soon as its bytes are in, so the state is the
    leaves' CIDs and sizes plus at most one partial leaf: a few megabytes for
    a 10 GB file. The tree over the leaves is built when `cid()` is asked.
    """

    def __init__(self) -> None:
        """Start with no bytes."""
        self._leaves: list[tuple[bytes, int, int]] = []
        self._partial = bytearray()

    def _add_leaf(self, block: bytes | memoryview) -> None:
        """Record one leaf's CID and size."""
        self._leaves.append(_leaf(block))

    def update(self, data: bytes) -> None:
        """Take the next piece of the file."""
        view = memoryview(data)
        if self._partial:
            take = CHUNK_BYTES - len(self._partial)
            self._partial += view[:take]
            view = view[take:]
            if len(self._partial) < CHUNK_BYTES:
                return
            self._add_leaf(self._partial)
            self._partial = bytearray()
        while len(view) >= CHUNK_BYTES:
            self._add_leaf(view[:CHUNK_BYTES])
            view = view[CHUNK_BYTES:]
        self._partial += view

    def cid(self) -> str:
        """The CID of everything fed so far.

        Returns:
            The CIDv1 in base32 (`bafk…` for one chunk, `bafy…` otherwise)
        """
        level = list(self._leaves)
        if self._partial or not level:
            level.append(_leaf(self._partial))
        while len(level) > 1:
            level = [
                _node(level[i : i + MAX_LINKS]) for i in range(0, len(level), MAX_LINKS)
            ]
        return "b" + base64.b32encode(level[0][0]).decode().lower().rstrip("=")

__init__()

Start with no bytes.

Source code in src/stargazer/utils/cid.py
def __init__(self) -> None:
    """Start with no bytes."""
    self._leaves: list[tuple[bytes, int, int]] = []
    self._partial = bytearray()

cid()

The CID of everything fed so far.

Returns:

Type Description
str

The CIDv1 in base32 (bafk… for one chunk, bafy… otherwise)

Source code in src/stargazer/utils/cid.py
def cid(self) -> str:
    """The CID of everything fed so far.

    Returns:
        The CIDv1 in base32 (`bafk…` for one chunk, `bafy…` otherwise)
    """
    level = list(self._leaves)
    if self._partial or not level:
        level.append(_leaf(self._partial))
    while len(level) > 1:
        level = [
            _node(level[i : i + MAX_LINKS]) for i in range(0, len(level), MAX_LINKS)
        ]
    return "b" + base64.b32encode(level[0][0]).decode().lower().rstrip("=")

update(data)

Take the next piece of the file.

Source code in src/stargazer/utils/cid.py
def update(self, data: bytes) -> None:
    """Take the next piece of the file."""
    view = memoryview(data)
    if self._partial:
        take = CHUNK_BYTES - len(self._partial)
        self._partial += view[:take]
        view = view[take:]
        if len(self._partial) < CHUNK_BYTES:
            return
        self._add_leaf(self._partial)
        self._partial = bytearray()
    while len(view) >= CHUNK_BYTES:
        self._add_leaf(view[:CHUNK_BYTES])
        view = view[CHUNK_BYTES:]
    self._partial += view

compute_cid(path)

Return the IPFS CID Pinata assigns path, reading it once in chunks.

Parameters:

Name Type Description Default
path Path

The file to hash

required

Returns:

Type Description
str

The CIDv1 in base32 (bafk… for one chunk, bafy… otherwise)

Source code in src/stargazer/utils/cid.py
def compute_cid(path: Path) -> str:
    """Return the IPFS CID Pinata assigns `path`, reading it once in chunks.

    Args:
        path: The file to hash

    Returns:
        The CIDv1 in base32 (`bafk…` for one chunk, `bafy…` otherwise)
    """
    builder = CidBuilder()
    with Path(path).open("rb") as fh:
        while block := fh.read(CHUNK_BYTES):
            builder.update(block)
    return builder.cid()

The asset index: SQLite, Postgres or Aurora DSQL.

The metadata half of asset storage: one row per asset, keyed by its CID, with where the bytes live (uri), the original filename, and the asset's keyvalues as a JSON object. query() matches every filter key exactly; a list value matches any of its entries, in one query. make_index opens the index a STARGAZER_INDEX_URL names.

Locally, SqliteIndex is the index itself. On the devbox it's a Postgres server that the dashboard and every pod reach through PostgresIndex. On Union it's the user's DsqlIndex table, which their dashboard and every pod in their project reach directly. Beside each index table is a state table (state_table_name) for the dashboard's own per-user state.

spec: docs/architecture/types.md

DsqlIndex

Bases: PostgresIndex

A user's asset index table on Aurora DSQL, reached as their own login.

The URL names the cluster endpoint, the user's database role, their table and the IAM role mapped to that database role: dsql://u_jane@<id>.dsql.us-west-2.on.aws/assets.u_jane?role_arn=<arn>. The index and state tables, the database role and the IAM role are made by onboarding; the database role can't create anything, so this never runs DDL. The cluster's admin needs no role_arn: it signs with the caller's own AWS credentials (the dsql test tier connects that way).

Each call connects afresh, so DSQL ending a connection after an hour never reaches a caller. The IAM role's credentials and the auth token are cached for the process; the token is signed locally, so the only remote calls per connection are STS when the credentials near expiry and the connection itself.

Usage

index = DsqlIndex(os.environ["STARGAZER_INDEX_URL"]) rows = await index.query({"companion_of": cid})

Source code in src/stargazer/utils/index.py
class DsqlIndex(PostgresIndex):
    """A user's asset index table on Aurora DSQL, reached as their own login.

    The URL names the cluster endpoint, the user's database role, their
    table and the IAM role mapped to that database role:
    `dsql://u_jane@<id>.dsql.us-west-2.on.aws/assets.u_jane?role_arn=<arn>`.
    The index and state tables, the database role and the IAM role are made
    by onboarding; the database role can't create anything, so this never
    runs DDL. The cluster's `admin` needs no `role_arn`: it signs with the
    caller's own AWS credentials (the dsql test tier connects that way).

    Each call connects afresh, so DSQL ending a connection after an hour
    never reaches a caller. The IAM role's credentials and the auth token
    are cached for the process; the token is signed locally, so the only
    remote calls per connection are STS when the credentials near expiry
    and the connection itself.

    Usage:
        index = DsqlIndex(os.environ["STARGAZER_INDEX_URL"])
        rows = await index.query({"companion_of": cid})
    """

    # Refresh the IAM role's credentials this long before they expire, and
    # sign a new token after this long (DSQL tokens last 15 minutes).
    _CREDENTIALS_MARGIN = 300
    _TOKEN_LIFETIME = 600

    def __init__(self, url: str):
        """Read the cluster, login, table and IAM role from `url`; no connection.

        Args:
            url: A `dsql://` index URL (see the class docstring)

        Raises:
            ValueError: when `url` has no login or table, a login other
                than `admin` has no `role_arn`, or its host isn't a DSQL
                endpoint
        """
        parts = urlsplit(url)
        host = parts.hostname or ""
        labels = host.split(".")
        role_arn = parse_qs(parts.query).get("role_arn", [""])[0]
        table = parts.path.lstrip("/")
        if len(labels) < 3 or labels[1] != "dsql":
            raise ValueError(f"Not a DSQL endpoint (<id>.dsql.<region>...): {host}")
        if not (parts.username and table) or (
            parts.username != "admin" and not role_arn
        ):
            raise ValueError(
                "A DSQL index URL names its login, table and IAM role: "
                "dsql://<login>@<endpoint>/<schema>.<table>?role_arn=<arn> "
                "(the admin signs with the caller's own credentials instead)"
            )
        self.endpoint = host
        self.region = labels[2]
        self.user = parts.username
        self.table = table
        self.role_arn = role_arn
        self._lock = threading.Lock()
        self._dsql = None
        self._credentials_expire = 0.0
        self._token = ""
        self._token_expires = 0.0

    def _password(self) -> str:
        """A DSQL auth token for `user`, signed with the IAM role's credentials.

        The role is assumed with the pod's own service-account token, which
        STS accepts for the role only from the user's namespace.
        """
        with self._lock:
            now = time.time()
            if not self.role_arn:
                self._dsql = self._dsql or boto3.Session().client(
                    "dsql", region_name=self.region
                )
                self._credentials_expire = float("inf")
            elif now >= self._credentials_expire:
                web_token = Path(os.environ["AWS_WEB_IDENTITY_TOKEN_FILE"]).read_text()
                creds = boto3.client(
                    "sts", region_name=self.region
                ).assume_role_with_web_identity(
                    RoleArn=self.role_arn,
                    RoleSessionName="stargazer-index",
                    WebIdentityToken=web_token,
                )["Credentials"]
                self._dsql = boto3.Session(
                    aws_access_key_id=creds["AccessKeyId"],
                    aws_secret_access_key=creds["SecretAccessKey"],
                    aws_session_token=creds["SessionToken"],
                ).client("dsql", region_name=self.region)
                self._credentials_expire = (
                    creds["Expiration"].timestamp() - self._CREDENTIALS_MARGIN
                )
                self._token_expires = 0.0
            if now >= self._token_expires:
                sign = (
                    self._dsql.generate_db_connect_admin_auth_token
                    if self.user == "admin"
                    else self._dsql.generate_db_connect_auth_token
                )
                self._token = sign(self.endpoint, self.region)
                self._token_expires = min(
                    now + self._TOKEN_LIFETIME, self._credentials_expire
                )
            return self._token

    def _connect(self, autocommit: bool) -> psycopg.Connection:
        """A verified TLS connection to the cluster as `user`, over IPv4.

        Task pods have no IPv6 route and the endpoint resolves to both, so
        leaving the choice to libpq costs a failed IPv6 attempt on every
        connect (measured on the tenant). certifi's bundle carries the
        Amazon root CA that signs the cluster's certificate.
        """
        ipv4 = socket.getaddrinfo(
            self.endpoint, 5432, socket.AF_INET, socket.SOCK_STREAM
        )[0][4][0]
        ssl = {"sslmode": "verify-full", "sslrootcert": certifi.where()}
        if psycopg.pq.version() >= 170000:
            ssl["sslnegotiation"] = "direct"
        return psycopg.connect(
            host=self.endpoint,
            hostaddr=ipv4,
            port=5432,
            dbname="postgres",
            user=self.user,
            password=self._password(),
            connect_timeout=15,
            autocommit=autocommit,
            **ssl,
        )

__init__(url)

Read the cluster, login, table and IAM role from url; no connection.

Parameters:

Name Type Description Default
url str

A dsql:// index URL (see the class docstring)

required

Raises:

Type Description
ValueError

when url has no login or table, a login other than admin has no role_arn, or its host isn't a DSQL endpoint

Source code in src/stargazer/utils/index.py
def __init__(self, url: str):
    """Read the cluster, login, table and IAM role from `url`; no connection.

    Args:
        url: A `dsql://` index URL (see the class docstring)

    Raises:
        ValueError: when `url` has no login or table, a login other
            than `admin` has no `role_arn`, or its host isn't a DSQL
            endpoint
    """
    parts = urlsplit(url)
    host = parts.hostname or ""
    labels = host.split(".")
    role_arn = parse_qs(parts.query).get("role_arn", [""])[0]
    table = parts.path.lstrip("/")
    if len(labels) < 3 or labels[1] != "dsql":
        raise ValueError(f"Not a DSQL endpoint (<id>.dsql.<region>...): {host}")
    if not (parts.username and table) or (
        parts.username != "admin" and not role_arn
    ):
        raise ValueError(
            "A DSQL index URL names its login, table and IAM role: "
            "dsql://<login>@<endpoint>/<schema>.<table>?role_arn=<arn> "
            "(the admin signs with the caller's own credentials instead)"
        )
    self.endpoint = host
    self.region = labels[2]
    self.user = parts.username
    self.table = table
    self.role_arn = role_arn
    self._lock = threading.Lock()
    self._dsql = None
    self._credentials_expire = 0.0
    self._token = ""
    self._token_expires = 0.0

PostgresIndex

Bases: SqlIndex

Asset metadata index in a Postgres database, keyvalues as jsonb.

Each call opens its own connection, so concurrent writers are serialized by Postgres's row locks rather than a lock here. A write refused with a serialization failure is rerun whole, with jittered backoff.

Usage

index = PostgresIndex("postgresql://user:pass@host:5432/db") rows = await index.query({"companion_of": cid})

Source code in src/stargazer/utils/index.py
class PostgresIndex(SqlIndex):
    """Asset metadata index in a Postgres database, keyvalues as `jsonb`.

    Each call opens its own connection, so concurrent writers are serialized
    by Postgres's row locks rather than a lock here. A write refused with a
    serialization failure is rerun whole, with jittered backoff.

    Usage:
        index = PostgresIndex("postgresql://user:pass@host:5432/db")
        rows = await index.query({"companion_of": cid})
    """

    _JSON_TYPE = "JSONB"
    _KEYVALUE = "(keyvalues ->> CAST(? AS TEXT))"
    _MERGE = "keyvalues || CAST(? AS JSONB)"

    def __init__(self, url: str, table: str = "assets"):
        """Connect to `url` and create the index and state tables if they're missing.

        Args:
            url: A `postgresql://` connection URL
            table: The table, schema-qualified if it isn't in the search path
        """
        self.url = url
        self.table = table
        with self._connect(autocommit=True) as conn:
            conn.execute(postgres_table_sql(table))
            conn.execute(state_table_sql(table))

    def _connect(self, autocommit: bool) -> psycopg.Connection:
        """A new connection to the database."""
        return psycopg.connect(self.url, autocommit=autocommit)

    def _key(self, key: str) -> str:
        """`->>` takes the key name itself."""
        return key

    def write(self, work: Callable[[Callable], T]) -> T:
        """Run `work` in one transaction on a new connection, rerun on conflict.

        DSQL refuses a write that conflicts with a concurrent one instead of
        making it wait, and Postgres does the same under serializable
        isolation, both with SQLSTATE 40001, at a statement or at commit.
        """
        delay = 0.02
        for _ in range(_WRITE_ATTEMPTS - 1):
            try:
                return self._transaction(work)
            except psycopg.errors.SerializationFailure:
                time.sleep(random.uniform(0, delay))
                delay = min(delay * 2, 1.0)
        return self._transaction(work)

    def _transaction(self, work: Callable[[Callable], T]) -> T:
        """Run `work` in one transaction on a new connection."""
        with self._connect(autocommit=False) as conn:
            return work(lambda sql, params: conn.execute(_pg(sql), params))

    def read(self, sql: str, params: list) -> list[tuple]:
        """Run a read on a new connection."""
        with self._connect(autocommit=True) as conn:
            return conn.execute(_pg(sql), params).fetchall()

__init__(url, table='assets')

Connect to url and create the index and state tables if they're missing.

Parameters:

Name Type Description Default
url str

A postgresql:// connection URL

required
table str

The table, schema-qualified if it isn't in the search path

'assets'
Source code in src/stargazer/utils/index.py
def __init__(self, url: str, table: str = "assets"):
    """Connect to `url` and create the index and state tables if they're missing.

    Args:
        url: A `postgresql://` connection URL
        table: The table, schema-qualified if it isn't in the search path
    """
    self.url = url
    self.table = table
    with self._connect(autocommit=True) as conn:
        conn.execute(postgres_table_sql(table))
        conn.execute(state_table_sql(table))

read(sql, params)

Run a read on a new connection.

Source code in src/stargazer/utils/index.py
def read(self, sql: str, params: list) -> list[tuple]:
    """Run a read on a new connection."""
    with self._connect(autocommit=True) as conn:
        return conn.execute(_pg(sql), params).fetchall()

write(work)

Run work in one transaction on a new connection, rerun on conflict.

DSQL refuses a write that conflicts with a concurrent one instead of making it wait, and Postgres does the same under serializable isolation, both with SQLSTATE 40001, at a statement or at commit.

Source code in src/stargazer/utils/index.py
def write(self, work: Callable[[Callable], T]) -> T:
    """Run `work` in one transaction on a new connection, rerun on conflict.

    DSQL refuses a write that conflicts with a concurrent one instead of
    making it wait, and Postgres does the same under serializable
    isolation, both with SQLSTATE 40001, at a statement or at commit.
    """
    delay = 0.02
    for _ in range(_WRITE_ATTEMPTS - 1):
        try:
            return self._transaction(work)
        except psycopg.errors.SerializationFailure:
            time.sleep(random.uniform(0, delay))
            delay = min(delay * 2, 1.0)
    return self._transaction(work)

SqlIndex

The index's five calls, written once in SQL that SQLite and Postgres share.

Subclasses give what the engines spell differently: the JSON column type, one keyvalue's lookup and the merge (each with a ? parameter), and how statements run, and set table. Statements use ? placeholders throughout. write and read are public so the dashboard can keep its own rows in state_table, the table beside table. Writes are acknowledged only after they commit, so a downstream task always sees its upstream task's rows, and a merge is one statement, so concurrent merges each land.

Source code in src/stargazer/utils/index.py
class SqlIndex:
    """The index's five calls, written once in SQL that SQLite and Postgres share.

    Subclasses give what the engines spell differently: the JSON column type,
    one keyvalue's lookup and the merge (each with a `?` parameter), and how
    statements run, and set `table`. Statements use `?` placeholders
    throughout. `write` and `read` are public so the dashboard can keep its
    own rows in `state_table`, the table beside `table`. Writes are
    acknowledged only after they commit, so a downstream task always sees its
    upstream task's rows, and a merge is one statement, so concurrent merges
    each land.
    """

    table: str
    _JSON_TYPE: str
    _KEYVALUE: str
    _MERGE: str

    @property
    def state_table(self) -> str:
        """The dashboard's per-user state table, beside the index table."""
        return state_table_name(self.table)

    def _key(self, key: str) -> str:
        """The `_KEYVALUE` parameter that names `key`."""
        raise NotImplementedError

    def write(self, work: Callable[[Callable], T]) -> T:
        """Run `work(execute)` as one committed transaction."""
        raise NotImplementedError

    def read(self, sql: str, params: list) -> list[tuple]:
        """Rows from one read-only statement."""
        raise NotImplementedError

    def _where(self, filters: dict) -> tuple[str, list]:
        """SQL WHERE clause and parameters for exact-match keyvalue filters."""
        clauses: list[str] = []
        params: list = []
        for key, value in filters.items():
            if isinstance(value, list):
                if not value:
                    return "FALSE", []
                marks = ", ".join("?" * len(value))
                clauses.append(f"{self._KEYVALUE} IN ({marks})")
                params += [self._key(key), *value]
            else:
                clauses.append(f"{self._KEYVALUE} = ?")
                params += [self._key(key), value]
        return (" AND ".join(clauses) or "TRUE"), params

    def _upsert_sync(self, record: dict) -> dict:
        """Insert or replace one row, warning when its keyvalues change."""
        cid = record["cid"]
        keyvalues = dict(record["keyvalues"])

        def work(execute):
            """Read the old keyvalues and write the row, in one transaction."""
            existing = execute(
                f"SELECT keyvalues FROM {self.table} WHERE cid = ?", [cid]
            ).fetchone()
            if existing is not None:
                changed = _changed_keys(_decode(existing[0]), keyvalues)
                if changed:
                    logger.warning(
                        f"Asset {cid} re-uploaded with different metadata; "
                        f"replacing keys {changed}"
                    )
            return execute(
                f"INSERT INTO {self.table} ({_COLUMNS}) "
                f"VALUES (?, ?, ?, CAST(? AS {self._JSON_TYPE}), ?) "
                "ON CONFLICT (cid) DO UPDATE SET uri = excluded.uri, "
                "name = excluded.name, keyvalues = excluded.keyvalues "
                f"RETURNING {_COLUMNS}",
                [
                    cid,
                    record["uri"],
                    record["name"],
                    json.dumps(keyvalues),
                    datetime.now(UTC).isoformat(),
                ],
            ).fetchone()

        return _row(self.write(work))

    def _merge_sync(self, cid: str, patch: dict[str, str]) -> dict:
        """Merge keyvalues onto a row in one statement."""
        stored = self.write(
            lambda execute: execute(
                f"UPDATE {self.table} SET keyvalues = {self._MERGE} WHERE cid = ? "
                f"RETURNING {_COLUMNS}",
                [json.dumps(patch), cid],
            ).fetchone()
        )
        if stored is None:
            raise ValueError(f"No index row for cid {cid}")
        return _row(stored)

    def _delete_sync(self, cid: str) -> None:
        """Remove a row."""
        self.write(
            lambda execute: execute(f"DELETE FROM {self.table} WHERE cid = ?", [cid])
        )

    async def upsert(self, record: dict) -> dict:
        """Insert a row, or replace an existing row's uri, name and keyvalues.

        A different set of keyvalues for an existing CID logs a WARNING naming
        the keys that changed. `created_at` keeps its first value.

        Args:
            record: `cid`, `uri`, `name` and `keyvalues`

        Returns:
            The stored row
        """
        return await asyncio.to_thread(self._upsert_sync, record)

    async def query(self, filters: dict) -> list[dict]:
        """Rows matching every filter, newest first.

        Args:
            filters: Keyvalue filters; a list value matches any of its entries

        Returns:
            Matching rows: `cid`, `uri`, `name`, `keyvalues`, `created_at`
        """
        where, params = self._where(filters)
        rows = await asyncio.to_thread(
            self.read,
            f"SELECT {_COLUMNS} FROM {self.table} WHERE {where} "
            "ORDER BY created_at DESC",
            params,
        )
        return [_row(r) for r in rows]

    async def get(self, cid: str) -> dict | None:
        """One row by CID, or None."""
        rows = await asyncio.to_thread(
            self.read, f"SELECT {_COLUMNS} FROM {self.table} WHERE cid = ?", [cid]
        )
        return _row(rows[0]) if rows else None

    async def merge(self, cid: str, patch: dict[str, str]) -> dict:
        """Merge keyvalues onto a row: supplied keys are set, others kept.

        Raises:
            ValueError: when no row exists for `cid`
        """
        return await asyncio.to_thread(self._merge_sync, cid, patch)

    async def delete(self, cid: str) -> None:
        """Remove a row. Deleting a missing row is a no-op."""
        await asyncio.to_thread(self._delete_sync, cid)

state_table property

The dashboard's per-user state table, beside the index table.

delete(cid) async

Remove a row. Deleting a missing row is a no-op.

Source code in src/stargazer/utils/index.py
async def delete(self, cid: str) -> None:
    """Remove a row. Deleting a missing row is a no-op."""
    await asyncio.to_thread(self._delete_sync, cid)

get(cid) async

One row by CID, or None.

Source code in src/stargazer/utils/index.py
async def get(self, cid: str) -> dict | None:
    """One row by CID, or None."""
    rows = await asyncio.to_thread(
        self.read, f"SELECT {_COLUMNS} FROM {self.table} WHERE cid = ?", [cid]
    )
    return _row(rows[0]) if rows else None

merge(cid, patch) async

Merge keyvalues onto a row: supplied keys are set, others kept.

Raises:

Type Description
ValueError

when no row exists for cid

Source code in src/stargazer/utils/index.py
async def merge(self, cid: str, patch: dict[str, str]) -> dict:
    """Merge keyvalues onto a row: supplied keys are set, others kept.

    Raises:
        ValueError: when no row exists for `cid`
    """
    return await asyncio.to_thread(self._merge_sync, cid, patch)

query(filters) async

Rows matching every filter, newest first.

Parameters:

Name Type Description Default
filters dict

Keyvalue filters; a list value matches any of its entries

required

Returns:

Type Description
list[dict]

Matching rows: cid, uri, name, keyvalues, created_at

Source code in src/stargazer/utils/index.py
async def query(self, filters: dict) -> list[dict]:
    """Rows matching every filter, newest first.

    Args:
        filters: Keyvalue filters; a list value matches any of its entries

    Returns:
        Matching rows: `cid`, `uri`, `name`, `keyvalues`, `created_at`
    """
    where, params = self._where(filters)
    rows = await asyncio.to_thread(
        self.read,
        f"SELECT {_COLUMNS} FROM {self.table} WHERE {where} "
        "ORDER BY created_at DESC",
        params,
    )
    return [_row(r) for r in rows]

read(sql, params)

Rows from one read-only statement.

Source code in src/stargazer/utils/index.py
def read(self, sql: str, params: list) -> list[tuple]:
    """Rows from one read-only statement."""
    raise NotImplementedError

upsert(record) async

Insert a row, or replace an existing row's uri, name and keyvalues.

A different set of keyvalues for an existing CID logs a WARNING naming the keys that changed. created_at keeps its first value.

Parameters:

Name Type Description Default
record dict

cid, uri, name and keyvalues

required

Returns:

Type Description
dict

The stored row

Source code in src/stargazer/utils/index.py
async def upsert(self, record: dict) -> dict:
    """Insert a row, or replace an existing row's uri, name and keyvalues.

    A different set of keyvalues for an existing CID logs a WARNING naming
    the keys that changed. `created_at` keeps its first value.

    Args:
        record: `cid`, `uri`, `name` and `keyvalues`

    Returns:
        The stored row
    """
    return await asyncio.to_thread(self._upsert_sync, record)

write(work)

Run work(execute) as one committed transaction.

Source code in src/stargazer/utils/index.py
def write(self, work: Callable[[Callable], T]) -> T:
    """Run `work(execute)` as one committed transaction."""
    raise NotImplementedError

SqliteIndex

Bases: SqlIndex

Asset metadata index in one SQLite file.

Writes go through one connection behind a threading.Lock, taken inside the worker thread rather than an asyncio.Lock so one index can serve callers on different event loops; WAL mode lets reads run alongside a write.

Usage

index = SqliteIndex(Path("~/.stargazer/index.db").expanduser()) await index.upsert({"cid": cid, "uri": uri, "name": name, "keyvalues": kv}) rows = await index.query({"asset": "alignment", "sample_id": ["S1", "S2"]})

Source code in src/stargazer/utils/index.py
class SqliteIndex(SqlIndex):
    """Asset metadata index in one SQLite file.

    Writes go through one connection behind a `threading.Lock`, taken inside
    the worker thread rather than an `asyncio.Lock` so one index can serve
    callers on different event loops; WAL mode lets reads run alongside a
    write.

    Usage:
        index = SqliteIndex(Path("~/.stargazer/index.db").expanduser())
        await index.upsert({"cid": cid, "uri": uri, "name": name, "keyvalues": kv})
        rows = await index.query({"asset": "alignment", "sample_id": ["S1", "S2"]})
    """

    _JSON_TYPE = "TEXT"
    _KEYVALUE = "json_extract(keyvalues, ?)"
    _MERGE = "json_patch(keyvalues, ?)"

    def __init__(self, path: Path):
        """Open (or create) the index at `path`, in WAL mode.

        Args:
            path: The SQLite file; its parent directory is created if missing
        """
        self.path = Path(path)
        self.table = "assets"
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self._lock = threading.Lock()
        self._writer = sqlite3.connect(self.path, check_same_thread=False, timeout=30)
        self._writer.execute("PRAGMA journal_mode=WAL")
        self._writer.execute(_SCHEMA.format(table=self.table, json=self._JSON_TYPE))
        self._writer.execute(state_table_sql(self.table))
        self._writer.commit()

    def _key(self, key: str) -> str:
        """A JSON path to `key`, quoted so any key name is one path step."""
        return '$."' + key.replace('"', '\\"') + '"'

    def write(self, work: Callable[[Callable], T]) -> T:
        """Run `work` on the one writer connection, under the write lock."""
        with self._lock:
            try:
                result = work(self._writer.execute)
                self._writer.commit()
            except BaseException:
                self._writer.rollback()
                raise
        return result

    def read(self, sql: str, params: list) -> list[tuple]:
        """Run a read on its own connection (WAL readers never wait on the writer)."""
        conn = sqlite3.connect(self.path, timeout=30)
        try:
            return conn.execute(sql, params).fetchall()
        finally:
            conn.close()

__init__(path)

Open (or create) the index at path, in WAL mode.

Parameters:

Name Type Description Default
path Path

The SQLite file; its parent directory is created if missing

required
Source code in src/stargazer/utils/index.py
def __init__(self, path: Path):
    """Open (or create) the index at `path`, in WAL mode.

    Args:
        path: The SQLite file; its parent directory is created if missing
    """
    self.path = Path(path)
    self.table = "assets"
    self.path.parent.mkdir(parents=True, exist_ok=True)
    self._lock = threading.Lock()
    self._writer = sqlite3.connect(self.path, check_same_thread=False, timeout=30)
    self._writer.execute("PRAGMA journal_mode=WAL")
    self._writer.execute(_SCHEMA.format(table=self.table, json=self._JSON_TYPE))
    self._writer.execute(state_table_sql(self.table))
    self._writer.commit()

read(sql, params)

Run a read on its own connection (WAL readers never wait on the writer).

Source code in src/stargazer/utils/index.py
def read(self, sql: str, params: list) -> list[tuple]:
    """Run a read on its own connection (WAL readers never wait on the writer)."""
    conn = sqlite3.connect(self.path, timeout=30)
    try:
        return conn.execute(sql, params).fetchall()
    finally:
        conn.close()

write(work)

Run work on the one writer connection, under the write lock.

Source code in src/stargazer/utils/index.py
def write(self, work: Callable[[Callable], T]) -> T:
    """Run `work` on the one writer connection, under the write lock."""
    with self._lock:
        try:
            result = work(self._writer.execute)
            self._writer.commit()
        except BaseException:
            self._writer.rollback()
            raise
    return result

make_index(url)

The index named by STARGAZER_INDEX_URL.

A postgresql:// (or postgres://) URL is a Postgres database; a dsql:// URL is a user's table on Aurora DSQL (see DsqlIndex); anything else is a SQLite file path (a sqlite:// prefix is accepted and stripped, ~ is expanded).

Source code in src/stargazer/utils/index.py
def make_index(url: str) -> SqlIndex:
    """The index named by `STARGAZER_INDEX_URL`.

    A `postgresql://` (or `postgres://`) URL is a Postgres database; a
    `dsql://` URL is a user's table on Aurora DSQL (see `DsqlIndex`);
    anything else is a SQLite file path (a `sqlite://` prefix is accepted and
    stripped, `~` is expanded).
    """
    if url.startswith(DSQL_SCHEME):
        return DsqlIndex(url)
    if url.startswith(_POSTGRES_SCHEMES):
        return PostgresIndex(url)
    return SqliteIndex(Path(url.removeprefix("sqlite://")).expanduser())

postgres_table_sql(table)

CREATE TABLE IF NOT EXISTS for an index table on Postgres or DSQL.

Source code in src/stargazer/utils/index.py
def postgres_table_sql(table: str) -> str:
    """`CREATE TABLE IF NOT EXISTS` for an index table on Postgres or DSQL."""
    return _SCHEMA.format(table=table, json=PostgresIndex._JSON_TYPE)

state_table_name(table)

The state table beside index table table (assets.u_jane_state).

Source code in src/stargazer/utils/index.py
def state_table_name(table: str) -> str:
    """The state table beside index table `table` (`assets.u_jane_state`)."""
    return f"{table}_state"

state_table_sql(table)

CREATE TABLE IF NOT EXISTS for the state table beside index table table.

Source code in src/stargazer/utils/index.py
def state_table_sql(table: str) -> str:
    """`CREATE TABLE IF NOT EXISTS` for the state table beside index table `table`."""
    return _STATE_SCHEMA.format(table=state_table_name(table))

Pinata API v3 client for IPFS file storage.

Provides async interface for authenticated Pinata operations: - Uploading files with keyvalue metadata - Querying files by keyvalue pairs - Updating metadata and deleting files - Resumable (TUS) uploads, chunk by chunk, for a caller relaying a file

Stargazer uses Pinata's public network only: shared data lives there, readable by everyone and attributed by _owner. Every public write needs an _owner, and reads skip a file without one. Private data lives in each user's own store (stargazer.utils.storage), never on Pinata's private network.

spec: docs/architecture/configuration.md

PinataClient

Async client for Pinata API v3, on Pinata's public network (NETWORK).

Uploads (plain or TUS), keyvalue queries, metadata updates and deletes. A pure remote transport: caching and gateway downloads are the storage client's job (stargazer.utils.storage).

Usage

client = PinataClient() comp = Asset(keyvalues={"asset": "alignment", "sample_id": "NA12878"}) await client.upload(comp, Path("data.bam")) # sets comp.cid files = await client.query({"asset": "alignment", "sample_id": "NA12878"}) await client.delete(comp)

Source code in src/stargazer/utils/pinata.py
class PinataClient:
    """Async client for Pinata API v3, on Pinata's public network (`NETWORK`).

    Uploads (plain or TUS), keyvalue queries, metadata updates and deletes.
    A pure remote transport: caching and gateway downloads are the storage
    client's job (`stargazer.utils.storage`).

    Usage:
        client = PinataClient()
        comp = Asset(keyvalues={"asset": "alignment", "sample_id": "NA12878"})
        await client.upload(comp, Path("data.bam"))  # sets comp.cid
        files = await client.query({"asset": "alignment", "sample_id": "NA12878"})
        await client.delete(comp)
    """

    API_BASE = "https://api.pinata.cloud/v3"
    UPLOAD_BASE = "https://uploads.pinata.cloud/v3"

    def __init__(self, jwt: str | None = None):
        """Initialize Pinata client.

        Args:
            jwt: Pinata JWT token (defaults to PINATA_JWT from config)
        """
        self._jwt = jwt or os.environ.get("PINATA_JWT") or None

    @property
    def jwt(self) -> str:
        """Get JWT token, raising error if not set."""
        if not self._jwt:
            raise ValueError(
                "PINATA_JWT not set. Provide jwt= argument or "
                "set PINATA_JWT environment variable."
            )
        return self._jwt

    def _headers(self) -> dict:
        """Get authorization headers."""
        return {"Authorization": f"Bearer {self.jwt}"}

    async def _files(self, session: aiohttp.ClientSession, cid: str) -> list[dict]:
        """The public network's file listings for `cid`, owned or not."""
        async with session.get(
            f"{self.API_BASE}/files/{NETWORK}",
            headers=self._headers(),
            params={"cid": cid},
        ) as response:
            response.raise_for_status()
            return (await response.json()).get("data", {}).get("files", [])

    async def tus_create(
        self, filename: str, keyvalues: dict[str, str], length: int
    ) -> str:
        """Start a resumable TUS upload of ``length`` bytes on the public network.

        Args:
            filename: Name the uploaded file will carry
            keyvalues: The file's metadata, `_owner` included (checked here,
                not stamped)
            length: The whole file's size in bytes

        Returns:
            The upload's URL, which each chunk is sent to (``tus_patch``)

        Raises:
            ValueError: when ``keyvalues`` has no ``_owner``, more than
                ``MAX_KEYVALUES`` keys or one longer than
                ``MAX_KEYVALUE_CHARS``, before any request is made
        """
        _check_new_file(keyvalues)
        async with (
            aiohttp.ClientSession() as session,
            session.post(
                f"{self.UPLOAD_BASE}/files",
                headers={
                    **self._headers(),
                    "Tus-Resumable": "1.0.0",
                    "Content-Type": "application/offset+octet-stream",
                    "Upload-Length": str(length),
                    "Upload-Metadata": _tus_metadata(filename, keyvalues),
                },
            ) as response,
        ):
            response.raise_for_status()
            return response.headers["Location"]

    async def tus_patch(self, location: str, offset: int, chunk: bytes) -> str | None:
        """Send one chunk of a TUS upload, at most ``TUS_CHUNK_BYTES``.

        Args:
            location: The upload's URL, from ``tus_create``
            offset: Where in the file ``chunk`` starts
            chunk: The bytes

        Returns:
            The file's CID once the chunk completing it lands, else None
        """
        async with (
            aiohttp.ClientSession() as session,
            session.patch(
                location,
                headers={
                    **self._headers(),
                    "Tus-Resumable": "1.0.0",
                    "Upload-Offset": str(offset),
                    "Content-Type": "application/offset+octet-stream",
                },
                data=chunk,
            ) as response,
        ):
            response.raise_for_status()
            return response.headers.get("Upload-Cid")

    async def upload(self, component: Asset, path: Path) -> None:
        """Upload a local file to IPFS via Pinata as `component`. Sets component.cid.

        Files up to ``TUS_THRESHOLD_BYTES`` go via the plain multipart POST;
        larger files use the resumable TUS endpoint (chunked, no resume yet).

        Args:
            component: Asset whose keyvalues describe the file
            path: The local file to upload

        Raises:
            ValueError: when the stamped keyvalues have no ``_owner``, more
                than ``MAX_KEYVALUES`` keys or one longer than
                ``MAX_KEYVALUE_CHARS``, before any bytes are sent
        """
        path = Path(path)
        kv = _stamp_owner(component.to_keyvalues())
        if path.stat().st_size > TUS_THRESHOLD_BYTES:
            await self._upload_tus(component, path, kv)
        else:
            _check_new_file(kv)
            await self._upload_plain(component, path, kv)

    async def _upload_plain(
        self, component: Asset, path: Path, kv: dict[str, str]
    ) -> None:
        """Plain multipart POST upload (≤ TUS_THRESHOLD_BYTES)."""
        url = f"{self.UPLOAD_BASE}/files"

        async with aiohttp.ClientSession() as session:
            data = aiohttp.FormData()
            data.add_field("file", open(path, "rb"), filename=path.name)
            data.add_field("name", path.name)
            data.add_field("network", NETWORK)
            data.add_field("keyvalues", json.dumps(kv))

            async with session.post(
                url, headers=self._headers(), data=data
            ) as response:
                response.raise_for_status()
                result = await response.json()
                data_obj = result.get("data", result)
                component.cid = data_obj["cid"]

    async def _upload_tus(
        self, component: Asset, path: Path, kv: dict[str, str]
    ) -> None:
        """Resumable TUS upload for large files (chunked, no resume yet).

        Sends the file in ``TUS_CHUNK_BYTES`` chunks and takes the CID Pinata
        answers the last one with.
        """
        location = await self.tus_create(path.name, kv, path.stat().st_size)
        offset = 0
        cid = None
        async with aiofiles.open(path, "rb") as f:
            while chunk := await f.read(TUS_CHUNK_BYTES):
                cid = await self.tus_patch(location, offset, chunk)
                offset += len(chunk)
        if not cid:
            raise ValueError("TUS upload completed but returned no Upload-Cid")
        component.cid = cid

    async def query(self, keyvalues: dict[str, str]) -> list[dict]:
        """Query the public network's files by keyvalue metadata.

        Files with no `_owner` are left out.

        Args:
            keyvalues: Metadata key-value pairs to filter by

        Returns:
            List of matching file records with cid, name, keyvalues, and
            network
        """
        seen: dict[str, dict] = {}
        url = f"{self.API_BASE}/files/{NETWORK}"
        params: dict = {"pageLimit": 1000, "order": "DESC"}
        for key, value in keyvalues.items():
            params[f"keyvalues[{key}]"] = value

        async with aiohttp.ClientSession() as session:
            while True:
                async with session.get(
                    url, headers=self._headers(), params=params
                ) as response:
                    response.raise_for_status()
                    data = json.loads(await response.text())

                for f in data.get("data", {}).get("files", []):
                    if _owned(f):
                        seen.setdefault(f["cid"], _record(f))

                next_token = data.get("data", {}).get("next_page_token")
                if not next_token:
                    break
                params["pageToken"] = next_token

        return list(seen.values())

    async def get(self, cid: str) -> dict | None:
        """The public network's owned record for `cid`, or None if it has none.

        Returns:
            The record (cid, name, keyvalues, network), or None
        """
        async with aiohttp.ClientSession() as session:
            owned = [f for f in await self._files(session, cid) if _owned(f)]
        return _record(owned[0]) if owned else None

    async def delete(self, component: Asset) -> None:
        """Delete a file from Pinata by querying for its internal ID first.

        Args:
            component: Asset with cid set
        """
        async with aiohttp.ClientSession() as session:
            files = await self._files(session, component.cid)
            if not files:
                return
            async with session.delete(
                f"{self.API_BASE}/files/{NETWORK}/{files[0]['id']}",
                headers=self._headers(),
            ) as response:
                response.raise_for_status()

    async def update_metadata(self, cid: str, keyvalues: dict[str, str]) -> dict:
        """Merge keyvalues onto an existing file's metadata (Pinata PUT).

        Pinata's update is a **merge/upsert** (verified empirically): the
        supplied keys are added or overwritten, keys omitted from the patch
        are preserved — there is no key *removal*. The file bytes and CID are
        untouched (the CID is content-addressed; keyvalues live in the
        account index, not the file), so existing ``*_cid`` provenance edges
        pointing at this record stay valid. Pinata keys updates off the
        file's id, so the id is looked up by CID first.

        Args:
            cid: Content identifier of the file to update
            keyvalues: Metadata patch to merge (partial — only these keys
                change), ``_owner`` included: the caller names the owner

        Returns:
            The updated record: ``{cid, name, keyvalues, network}``

        Raises:
            ValueError: when the patch has no ``_owner`` or a key or value
                longer than ``MAX_KEYVALUE_CHARS``, before any request is
                made, or when no public file exists for ``cid``
        """
        kv = dict(keyvalues)
        _check_keyvalue_lengths(kv)
        _check_owner(kv)
        async with aiohttp.ClientSession() as session:
            files = await self._files(session, cid)
            if not files:
                raise ValueError(f"No public file for cid {cid}")
            async with session.put(
                f"{self.API_BASE}/files/{NETWORK}/{files[0]['id']}",
                headers=self._headers(),
                json={"keyvalues": kv},
            ) as response:
                response.raise_for_status()
                data = (await response.json()).get("data", {})
        return _record({"cid": cid, "keyvalues": kv, **data})

jwt property

Get JWT token, raising error if not set.

__init__(jwt=None)

Initialize Pinata client.

Parameters:

Name Type Description Default
jwt str | None

Pinata JWT token (defaults to PINATA_JWT from config)

None
Source code in src/stargazer/utils/pinata.py
def __init__(self, jwt: str | None = None):
    """Initialize Pinata client.

    Args:
        jwt: Pinata JWT token (defaults to PINATA_JWT from config)
    """
    self._jwt = jwt or os.environ.get("PINATA_JWT") or None

delete(component) async

Delete a file from Pinata by querying for its internal ID first.

Parameters:

Name Type Description Default
component Asset

Asset with cid set

required
Source code in src/stargazer/utils/pinata.py
async def delete(self, component: Asset) -> None:
    """Delete a file from Pinata by querying for its internal ID first.

    Args:
        component: Asset with cid set
    """
    async with aiohttp.ClientSession() as session:
        files = await self._files(session, component.cid)
        if not files:
            return
        async with session.delete(
            f"{self.API_BASE}/files/{NETWORK}/{files[0]['id']}",
            headers=self._headers(),
        ) as response:
            response.raise_for_status()

get(cid) async

The public network's owned record for cid, or None if it has none.

Returns:

Type Description
dict | None

The record (cid, name, keyvalues, network), or None

Source code in src/stargazer/utils/pinata.py
async def get(self, cid: str) -> dict | None:
    """The public network's owned record for `cid`, or None if it has none.

    Returns:
        The record (cid, name, keyvalues, network), or None
    """
    async with aiohttp.ClientSession() as session:
        owned = [f for f in await self._files(session, cid) if _owned(f)]
    return _record(owned[0]) if owned else None

query(keyvalues) async

Query the public network's files by keyvalue metadata.

Files with no _owner are left out.

Parameters:

Name Type Description Default
keyvalues dict[str, str]

Metadata key-value pairs to filter by

required

Returns:

Type Description
list[dict]

List of matching file records with cid, name, keyvalues, and

list[dict]

network

Source code in src/stargazer/utils/pinata.py
async def query(self, keyvalues: dict[str, str]) -> list[dict]:
    """Query the public network's files by keyvalue metadata.

    Files with no `_owner` are left out.

    Args:
        keyvalues: Metadata key-value pairs to filter by

    Returns:
        List of matching file records with cid, name, keyvalues, and
        network
    """
    seen: dict[str, dict] = {}
    url = f"{self.API_BASE}/files/{NETWORK}"
    params: dict = {"pageLimit": 1000, "order": "DESC"}
    for key, value in keyvalues.items():
        params[f"keyvalues[{key}]"] = value

    async with aiohttp.ClientSession() as session:
        while True:
            async with session.get(
                url, headers=self._headers(), params=params
            ) as response:
                response.raise_for_status()
                data = json.loads(await response.text())

            for f in data.get("data", {}).get("files", []):
                if _owned(f):
                    seen.setdefault(f["cid"], _record(f))

            next_token = data.get("data", {}).get("next_page_token")
            if not next_token:
                break
            params["pageToken"] = next_token

    return list(seen.values())

tus_create(filename, keyvalues, length) async

Start a resumable TUS upload of length bytes on the public network.

Parameters:

Name Type Description Default
filename str

Name the uploaded file will carry

required
keyvalues dict[str, str]

The file's metadata, _owner included (checked here, not stamped)

required
length int

The whole file's size in bytes

required

Returns:

Type Description
str

The upload's URL, which each chunk is sent to (tus_patch)

Raises:

Type Description
ValueError

when keyvalues has no _owner, more than MAX_KEYVALUES keys or one longer than MAX_KEYVALUE_CHARS, before any request is made

Source code in src/stargazer/utils/pinata.py
async def tus_create(
    self, filename: str, keyvalues: dict[str, str], length: int
) -> str:
    """Start a resumable TUS upload of ``length`` bytes on the public network.

    Args:
        filename: Name the uploaded file will carry
        keyvalues: The file's metadata, `_owner` included (checked here,
            not stamped)
        length: The whole file's size in bytes

    Returns:
        The upload's URL, which each chunk is sent to (``tus_patch``)

    Raises:
        ValueError: when ``keyvalues`` has no ``_owner``, more than
            ``MAX_KEYVALUES`` keys or one longer than
            ``MAX_KEYVALUE_CHARS``, before any request is made
    """
    _check_new_file(keyvalues)
    async with (
        aiohttp.ClientSession() as session,
        session.post(
            f"{self.UPLOAD_BASE}/files",
            headers={
                **self._headers(),
                "Tus-Resumable": "1.0.0",
                "Content-Type": "application/offset+octet-stream",
                "Upload-Length": str(length),
                "Upload-Metadata": _tus_metadata(filename, keyvalues),
            },
        ) as response,
    ):
        response.raise_for_status()
        return response.headers["Location"]

tus_patch(location, offset, chunk) async

Send one chunk of a TUS upload, at most TUS_CHUNK_BYTES.

Parameters:

Name Type Description Default
location str

The upload's URL, from tus_create

required
offset int

Where in the file chunk starts

required
chunk bytes

The bytes

required

Returns:

Type Description
str | None

The file's CID once the chunk completing it lands, else None

Source code in src/stargazer/utils/pinata.py
async def tus_patch(self, location: str, offset: int, chunk: bytes) -> str | None:
    """Send one chunk of a TUS upload, at most ``TUS_CHUNK_BYTES``.

    Args:
        location: The upload's URL, from ``tus_create``
        offset: Where in the file ``chunk`` starts
        chunk: The bytes

    Returns:
        The file's CID once the chunk completing it lands, else None
    """
    async with (
        aiohttp.ClientSession() as session,
        session.patch(
            location,
            headers={
                **self._headers(),
                "Tus-Resumable": "1.0.0",
                "Upload-Offset": str(offset),
                "Content-Type": "application/offset+octet-stream",
            },
            data=chunk,
        ) as response,
    ):
        response.raise_for_status()
        return response.headers.get("Upload-Cid")

update_metadata(cid, keyvalues) async

Merge keyvalues onto an existing file's metadata (Pinata PUT).

Pinata's update is a merge/upsert (verified empirically): the supplied keys are added or overwritten, keys omitted from the patch are preserved — there is no key removal. The file bytes and CID are untouched (the CID is content-addressed; keyvalues live in the account index, not the file), so existing *_cid provenance edges pointing at this record stay valid. Pinata keys updates off the file's id, so the id is looked up by CID first.

Parameters:

Name Type Description Default
cid str

Content identifier of the file to update

required
keyvalues dict[str, str]

Metadata patch to merge (partial — only these keys change), _owner included: the caller names the owner

required

Returns:

Type Description
dict

The updated record: {cid, name, keyvalues, network}

Raises:

Type Description
ValueError

when the patch has no _owner or a key or value longer than MAX_KEYVALUE_CHARS, before any request is made, or when no public file exists for cid

Source code in src/stargazer/utils/pinata.py
async def update_metadata(self, cid: str, keyvalues: dict[str, str]) -> dict:
    """Merge keyvalues onto an existing file's metadata (Pinata PUT).

    Pinata's update is a **merge/upsert** (verified empirically): the
    supplied keys are added or overwritten, keys omitted from the patch
    are preserved — there is no key *removal*. The file bytes and CID are
    untouched (the CID is content-addressed; keyvalues live in the
    account index, not the file), so existing ``*_cid`` provenance edges
    pointing at this record stay valid. Pinata keys updates off the
    file's id, so the id is looked up by CID first.

    Args:
        cid: Content identifier of the file to update
        keyvalues: Metadata patch to merge (partial — only these keys
            change), ``_owner`` included: the caller names the owner

    Returns:
        The updated record: ``{cid, name, keyvalues, network}``

    Raises:
        ValueError: when the patch has no ``_owner`` or a key or value
            longer than ``MAX_KEYVALUE_CHARS``, before any request is
            made, or when no public file exists for ``cid``
    """
    kv = dict(keyvalues)
    _check_keyvalue_lengths(kv)
    _check_owner(kv)
    async with aiohttp.ClientSession() as session:
        files = await self._files(session, cid)
        if not files:
            raise ValueError(f"No public file for cid {cid}")
        async with session.put(
            f"{self.API_BASE}/files/{NETWORK}/{files[0]['id']}",
            headers=self._headers(),
            json={"keyvalues": kv},
        ) as response:
            response.raise_for_status()
            data = (await response.json()).get("data", {})
    return _record({"cid": cid, "keyvalues": kv, **data})

upload(component, path) async

Upload a local file to IPFS via Pinata as component. Sets component.cid.

Files up to TUS_THRESHOLD_BYTES go via the plain multipart POST; larger files use the resumable TUS endpoint (chunked, no resume yet).

Parameters:

Name Type Description Default
component Asset

Asset whose keyvalues describe the file

required
path Path

The local file to upload

required

Raises:

Type Description
ValueError

when the stamped keyvalues have no _owner, more than MAX_KEYVALUES keys or one longer than MAX_KEYVALUE_CHARS, before any bytes are sent

Source code in src/stargazer/utils/pinata.py
async def upload(self, component: Asset, path: Path) -> None:
    """Upload a local file to IPFS via Pinata as `component`. Sets component.cid.

    Files up to ``TUS_THRESHOLD_BYTES`` go via the plain multipart POST;
    larger files use the resumable TUS endpoint (chunked, no resume yet).

    Args:
        component: Asset whose keyvalues describe the file
        path: The local file to upload

    Raises:
        ValueError: when the stamped keyvalues have no ``_owner``, more
            than ``MAX_KEYVALUES`` keys or one longer than
            ``MAX_KEYVALUE_CHARS``, before any bytes are sent
    """
    path = Path(path)
    kv = _stamp_owner(component.to_keyvalues())
    if path.stat().st_size > TUS_THRESHOLD_BYTES:
        await self._upload_tus(component, path, kv)
    else:
        _check_new_file(kv)
        await self._upload_plain(component, path, kv)

Query generation utilities for Stargazer.

Expands list-valued filters into one exact-match query per combination, for backends that can't match a list in one query (Pinata's keyvalue filters).

spec: docs/architecture/types.md

generate_query_combinations(filters)

One exact-match query per combination of the filters' list values.

Scalar filters appear in every query; an empty list matches nothing, so it yields no queries.

Parameters:

Name Type Description Default
filters dict[str, Any]

Filter dict with scalar or list values

required

Returns:

Type Description
list[dict[str, Any]]

List of query dicts representing all combinations

Example

generate_query_combinations({"build": "GRCh38", "tool": ["faidx", "gatk"]}) [{"build": "GRCh38", "tool": "faidx"}, {"build": "GRCh38", "tool": "gatk"}]

Source code in src/stargazer/utils/query.py
def generate_query_combinations(filters: dict[str, Any]) -> list[dict[str, Any]]:
    """One exact-match query per combination of the filters' list values.

    Scalar filters appear in every query; an empty list matches nothing, so
    it yields no queries.

    Args:
        filters: Filter dict with scalar or list values

    Returns:
        List of query dicts representing all combinations

    Example:
        >>> generate_query_combinations({"build": "GRCh38", "tool": ["faidx", "gatk"]})
        [{"build": "GRCh38", "tool": "faidx"}, {"build": "GRCh38", "tool": "gatk"}]
    """
    keys = list(filters)
    values = [v if isinstance(v, list) else [v] for v in filters.values()]
    return [dict(zip(keys, combo)) for combo in product(*values)]

Storage client.

Where asset bytes and metadata live, and the local cache in front of them.

  • Bytes go to an object store through flyte.storage: a local directory when run locally, the user bucket on Union, reached as the user's own role. Each file is stored once per user at <STARGAZER_STORE_ROOT>/users/<owner>/assets/<cid>/<name>, where the owner is STARGAZER_OWNER (the Union subject) or local. A file uploaded in pieces, whose CID is known only at the end, stays where it was written, users/<owner>/uploads/<upload id>/<name>, and its row points there.
  • Metadata goes to the asset index (stargazer.utils.index), one row per CID, written after the bytes so a row never points at a missing file. A write that doesn't land raises; nothing is retried behind the caller's back.
  • Public data lives on Pinata's public network. When PINATA_JWT is set, query() merges Pinata's public records in (the user's own row wins on a shared CID), and public files download from the IPFS gateway. companions() decides which public companions join the user's own.
  • The cache is <STARGAZER_LOCAL>/<cid>/<name>. Uploads seed it, downloads fill it, and Asset.fetch() hardlinks companions next to their asset. A file named by its own CID never changes; a companion linked into its asset's directory is relinked when a later fetch picks a different file of that name.

get_client() builds a client from the environment; default_client is the lazily built process-wide instance (_LazyClient).

spec: docs/architecture/configuration.md

StorageClient

Object-store bytes, an asset index, an optional public tier, a local cache.

Usage

client = get_client() await client.upload(asset, Path("s1.bam")) # sets asset.cid and asset.path local = await client.download(asset) # //s1.bam rows = await client.query({"asset": "alignment", "sample_id": "S1"})

Source code in src/stargazer/utils/storage.py
class StorageClient:
    """Object-store bytes, an asset index, an optional public tier, a local cache.

    Usage:
        client = get_client()
        await client.upload(asset, Path("s1.bam"))  # sets asset.cid and asset.path
        local = await client.download(asset)        # <local_dir>/<cid>/s1.bam
        rows = await client.query({"asset": "alignment", "sample_id": "S1"})
    """

    def __init__(
        self,
        local_dir: Path,
        store_root: str,
        index: SqlIndex,
        public: PinataClient | None = None,
        gateway: str | None = None,
    ):
        """Wire the client.

        Args:
            local_dir: Scratch space for task outputs and the download cache
            store_root: Object-store root (a local directory or a bucket URI)
            index: The asset index
            public: Pinata client for the public tier, or None for none
            gateway: IPFS gateway for public downloads (defaults to PINATA_GATEWAY)
        """
        self.local_dir = Path(local_dir)
        self.local_dir.mkdir(parents=True, exist_ok=True)
        self.store_root = store_root.rstrip("/")
        self.index = index
        self.public = public
        self.gateway = (gateway or os.environ["PINATA_GATEWAY"]).rstrip("/")

    def _cache_path(self, cid: str, name: str) -> Path:
        """Where a file lives in the local cache."""
        return self.local_dir / cid / name

    def gateway_uri(self, cid: str) -> str:
        """Where the public IPFS gateway serves `cid`."""
        return f"{self.gateway}/ipfs/{cid}"

    def _public_record(self, record: dict) -> dict:
        """A Pinata public record in index-row shape, located at the gateway."""
        cid = record["cid"]
        return {
            "cid": cid,
            "uri": self.gateway_uri(cid),
            "name": record.get("name") or cid,
            "keyvalues": record.get("keyvalues", {}),
        }

    async def upload(self, asset: Asset, path: Path) -> None:
        """Store `path` as `asset`: bytes to the store, then the index row.

        Sets `asset.cid` and `asset.path` (a File at the stored location) and
        seeds the local cache. Bytes the CID's row already points at, or
        already at the target path, aren't transferred again. Whatever the
        store or the index raises propagates: a row that doesn't land fails
        the upload.
        """
        path = Path(path)
        cid = await asyncio.to_thread(compute_cid, path)
        uri, name = await self._stored_copy(cid)
        if uri is None:
            uri, name = (
                f"{self.store_root}/users/{_owner()}/assets/{cid}/{path.name}",
                path.name,
            )
            if not await flyte.storage.exists(uri):
                await asyncio.to_thread(_makedirs, uri.rsplit("/", 1)[0])
                await flyte.storage.put(str(path), uri)
        await self._index_as(asset, cid, uri, name)
        _materialize(path, self._cache_path(cid, name))

    async def _index_as(self, asset: Asset, cid: str, uri: str, name: str) -> None:
        """Write `asset`'s row, stamped with `_owner`, and point it at `uri`."""
        await self.index.upsert(
            {
                "cid": cid,
                "uri": uri,
                "name": name,
                "keyvalues": _stamp_owner(asset.to_keyvalues()),
            }
        )
        _set_stored(asset, cid, uri, name)

    async def _stored_copy(self, cid: str) -> tuple[str | None, str | None]:
        """The `(uri, name)` of the file the CID's row points at, if it exists."""
        row = await self.index.get(cid)
        if row is not None and await flyte.storage.exists(row["uri"]):
            return row["uri"], row["name"]
        return None, None

    def staging_uri(self, upload_id: str, name: str) -> str:
        """Where a file uploaded in pieces is written, before its CID is known."""
        return f"{self.store_root}/users/{_owner()}/uploads/{upload_id}/{name}"

    async def adopt(self, asset: Asset, uri: str, cid: str) -> None:
        """Index a file already written to the store at `uri` as `asset`.

        For bytes that reached the store some other way than `upload`, such as
        a resumable upload from the browser, whose CID was worked out as they
        passed. When the user already has the CID stored, that copy and its
        row stay as they are and `uri` is deleted. Otherwise the row points at
        `uri`, stamped with `_owner` like any upload. Sets `asset.cid` and
        `asset.path` either way.
        """
        stored, name = await self._stored_copy(cid)
        if stored is not None and stored != uri:
            await asyncio.to_thread(_remove, uri)
            _set_stored(asset, cid, stored, name)
        else:
            await self._index_as(asset, cid, uri, uri.rsplit("/", 1)[-1])

    async def download(self, asset: Asset, directory: Path | None = None) -> Path:
        """Make a local copy of `asset` and return its path.

        The copy lives at `<local_dir>/<cid>/<name>`; with `directory` it is
        also linked into that directory (how companions land beside their
        asset), replacing a different file of the same name there. An asset
        with no CID is a local file that was never stored: it is returned in
        place. An asset with a CID but no file is located through the index,
        or failing that the public IPFS gateway, and its `path` is set to
        what was found.

        Raises:
            FileNotFoundError: when the asset has neither a file nor a CID, or
                no CID and its local file doesn't exist
        """
        if asset.path is None:
            if not asset.cid:
                raise FileNotFoundError("Asset has neither a file nor a CID")
            row = await self.index.get(asset.cid)
            if row is not None:
                asset.path = File(path=row["uri"], name=row["name"], hash=asset.cid)
            else:
                asset.path = File(
                    path=self.gateway_uri(asset.cid), name=asset.cid, hash=asset.cid
                )
        source = asset.path.path
        if not asset.cid:
            local = Path(source)
            if not local.exists():
                raise FileNotFoundError(f"{source} doesn't exist and has no CID")
            return local

        name = asset.path.name or source.rstrip("/").rsplit("/", 1)[-1]
        own = self._cache_path(asset.cid, name)
        if not own.exists():
            if Path(source).exists():
                _materialize(Path(source), own)
            else:
                own.parent.mkdir(parents=True, exist_ok=True)
                part = own.with_name(own.name + ".part")
                if source.startswith(("http://", "https://")):
                    await _fetch_http(source, part)
                else:
                    await flyte.storage.get(source, str(part))
                part.rename(own)
        if directory is None:
            return own
        target = Path(directory) / name
        # The directory is named for another asset's CID, so a file already
        # at `target` may be a different companion of the same name, linked
        # by an earlier fetch.
        if target.exists() and not target.samefile(own):
            target.unlink(missing_ok=True)
        _materialize(own, target)
        return target

    async def query(self, filters: dict) -> list[dict]:
        """Records matching every filter: the user's rows, then public-only ones.

        Args:
            filters: Keyvalue filters; a list value matches any of its entries

        Returns:
            Rows with `cid`, `uri`, `name` and `keyvalues`
        """
        rows = await self.index.query(filters)
        if self.public is None:
            return rows
        mine = {r["cid"] for r in rows}
        public: dict[str, dict] = {}
        for combo in generate_query_combinations(filters):
            for record in await self.public.query(combo):
                if record["cid"] not in mine:
                    public.setdefault(record["cid"], self._public_record(record))
        return [*rows, *public.values()]

    async def companions(self, cid: str) -> list[dict]:
        """Records whose `companion_of` is `cid`, from the caller and the publisher.

        The user's own rows come first, then public companions the caller
        published (`_owner` is `STARGAZER_OWNER`), so one published earlier
        and no longer in the store is fetched again. Then come public
        companions from whoever published `cid` itself; a file nobody
        published has no publisher to follow. Anyone else's are left out, so
        two publishers' builds of a popular reference never mix. A name
        already taken isn't taken again: of the user's own, the newest stands,
        and the publisher's fills in only what the user lacks.

        Returns:
            Rows with `cid`, `uri`, `name` and `keyvalues`
        """
        own: dict[str, dict] = {}
        for row in await self.index.query({"companion_of": cid}):
            own.setdefault(row["name"], row)
        rows = list(own.values())
        if self.public is None:
            return rows
        records = await self.public.query({"companion_of": cid})
        if not records:
            return rows
        parent = await self.public.get(cid)
        caller = os.environ.get("STARGAZER_OWNER")
        owners = [caller, parent["keyvalues"]["_owner"] if parent else None]
        taken = set(own)
        for owner in filter(None, owners):
            for record in records:
                public = self._public_record(record)
                if record["keyvalues"].get("_owner") == owner and (
                    public["name"] not in taken
                ):
                    taken.add(public["name"])
                    rows.append(public)
        return rows

    async def delete(self, asset: Asset) -> None:
        """Remove the asset's index row and, if it's in the store, its file.

        A row pointing at the IPFS gateway (a fetched bundle) has no stored
        file to remove. Idempotent.
        """
        row = await self.index.get(asset.cid)
        await self.index.delete(asset.cid)
        if row is not None and not row["uri"].startswith(("http://", "https://")):
            await asyncio.to_thread(_remove, row["uri"])

    async def publish(self, cid: str) -> dict:
        """Put one of the user's stored assets on the public tier, same CID.

        Uploads the stored file to Pinata's public network with its index
        row's keyvalues; `_owner` is restamped from `STARGAZER_OWNER`, as on
        any upload, and files over 100 MB go up in resumable chunks. The
        user's row stays, so the asset is in both tiers, and `query` keeps
        the row over the public duplicate.

        Args:
            cid: The stored asset to publish

        Returns:
            The public record: `cid`, `name` and `keyvalues`

        Raises:
            ValueError: with no public tier, for a CID that isn't in the
                user's index, for keyvalues over Pinata's limits (before
                anything is sent), or when Pinata assigns a different CID
        """
        if self.public is None:
            raise ValueError("No PINATA_JWT: there's no public tier to publish to")
        row = await self.index.get(cid)
        if row is None:
            raise ValueError(f"{cid} isn't in your storage")
        stored = Asset(cid=cid, path=File(path=row["uri"], name=row["name"], hash=cid))
        local = await self.download(stored)
        published = Asset(keyvalues=_stamp_owner(dict(row["keyvalues"])))
        await self.public.upload(published, local)
        # Our CIDs are computed to match Pinata's, so a mismatch is a bug.
        if published.cid != cid:
            raise ValueError(
                f"Pinata stored {cid} as {published.cid}; the CIDs should match"
            )
        return {
            "cid": cid,
            "name": row["name"],
            "keyvalues": published.to_keyvalues(),
        }

    async def update_metadata(self, cid: str, keyvalues: dict[str, str]) -> dict:
        """Merge a metadata patch onto the user's index row, restamping `_owner`.

        Args:
            cid: The record to update
            keyvalues: Keys to add or overwrite; others are kept

        Raises:
            ValueError: when the user has no row for `cid`
        """
        return await self.index.merge(cid, _stamp_owner(dict(keyvalues)))

__init__(local_dir, store_root, index, public=None, gateway=None)

Wire the client.

Parameters:

Name Type Description Default
local_dir Path

Scratch space for task outputs and the download cache

required
store_root str

Object-store root (a local directory or a bucket URI)

required
index SqlIndex

The asset index

required
public PinataClient | None

Pinata client for the public tier, or None for none

None
gateway str | None

IPFS gateway for public downloads (defaults to PINATA_GATEWAY)

None
Source code in src/stargazer/utils/storage.py
def __init__(
    self,
    local_dir: Path,
    store_root: str,
    index: SqlIndex,
    public: PinataClient | None = None,
    gateway: str | None = None,
):
    """Wire the client.

    Args:
        local_dir: Scratch space for task outputs and the download cache
        store_root: Object-store root (a local directory or a bucket URI)
        index: The asset index
        public: Pinata client for the public tier, or None for none
        gateway: IPFS gateway for public downloads (defaults to PINATA_GATEWAY)
    """
    self.local_dir = Path(local_dir)
    self.local_dir.mkdir(parents=True, exist_ok=True)
    self.store_root = store_root.rstrip("/")
    self.index = index
    self.public = public
    self.gateway = (gateway or os.environ["PINATA_GATEWAY"]).rstrip("/")

adopt(asset, uri, cid) async

Index a file already written to the store at uri as asset.

For bytes that reached the store some other way than upload, such as a resumable upload from the browser, whose CID was worked out as they passed. When the user already has the CID stored, that copy and its row stay as they are and uri is deleted. Otherwise the row points at uri, stamped with _owner like any upload. Sets asset.cid and asset.path either way.

Source code in src/stargazer/utils/storage.py
async def adopt(self, asset: Asset, uri: str, cid: str) -> None:
    """Index a file already written to the store at `uri` as `asset`.

    For bytes that reached the store some other way than `upload`, such as
    a resumable upload from the browser, whose CID was worked out as they
    passed. When the user already has the CID stored, that copy and its
    row stay as they are and `uri` is deleted. Otherwise the row points at
    `uri`, stamped with `_owner` like any upload. Sets `asset.cid` and
    `asset.path` either way.
    """
    stored, name = await self._stored_copy(cid)
    if stored is not None and stored != uri:
        await asyncio.to_thread(_remove, uri)
        _set_stored(asset, cid, stored, name)
    else:
        await self._index_as(asset, cid, uri, uri.rsplit("/", 1)[-1])

companions(cid) async

Records whose companion_of is cid, from the caller and the publisher.

The user's own rows come first, then public companions the caller published (_owner is STARGAZER_OWNER), so one published earlier and no longer in the store is fetched again. Then come public companions from whoever published cid itself; a file nobody published has no publisher to follow. Anyone else's are left out, so two publishers' builds of a popular reference never mix. A name already taken isn't taken again: of the user's own, the newest stands, and the publisher's fills in only what the user lacks.

Returns:

Type Description
list[dict]

Rows with cid, uri, name and keyvalues

Source code in src/stargazer/utils/storage.py
async def companions(self, cid: str) -> list[dict]:
    """Records whose `companion_of` is `cid`, from the caller and the publisher.

    The user's own rows come first, then public companions the caller
    published (`_owner` is `STARGAZER_OWNER`), so one published earlier
    and no longer in the store is fetched again. Then come public
    companions from whoever published `cid` itself; a file nobody
    published has no publisher to follow. Anyone else's are left out, so
    two publishers' builds of a popular reference never mix. A name
    already taken isn't taken again: of the user's own, the newest stands,
    and the publisher's fills in only what the user lacks.

    Returns:
        Rows with `cid`, `uri`, `name` and `keyvalues`
    """
    own: dict[str, dict] = {}
    for row in await self.index.query({"companion_of": cid}):
        own.setdefault(row["name"], row)
    rows = list(own.values())
    if self.public is None:
        return rows
    records = await self.public.query({"companion_of": cid})
    if not records:
        return rows
    parent = await self.public.get(cid)
    caller = os.environ.get("STARGAZER_OWNER")
    owners = [caller, parent["keyvalues"]["_owner"] if parent else None]
    taken = set(own)
    for owner in filter(None, owners):
        for record in records:
            public = self._public_record(record)
            if record["keyvalues"].get("_owner") == owner and (
                public["name"] not in taken
            ):
                taken.add(public["name"])
                rows.append(public)
    return rows

delete(asset) async

Remove the asset's index row and, if it's in the store, its file.

A row pointing at the IPFS gateway (a fetched bundle) has no stored file to remove. Idempotent.

Source code in src/stargazer/utils/storage.py
async def delete(self, asset: Asset) -> None:
    """Remove the asset's index row and, if it's in the store, its file.

    A row pointing at the IPFS gateway (a fetched bundle) has no stored
    file to remove. Idempotent.
    """
    row = await self.index.get(asset.cid)
    await self.index.delete(asset.cid)
    if row is not None and not row["uri"].startswith(("http://", "https://")):
        await asyncio.to_thread(_remove, row["uri"])

download(asset, directory=None) async

Make a local copy of asset and return its path.

The copy lives at <local_dir>/<cid>/<name>; with directory it is also linked into that directory (how companions land beside their asset), replacing a different file of the same name there. An asset with no CID is a local file that was never stored: it is returned in place. An asset with a CID but no file is located through the index, or failing that the public IPFS gateway, and its path is set to what was found.

Raises:

Type Description
FileNotFoundError

when the asset has neither a file nor a CID, or no CID and its local file doesn't exist

Source code in src/stargazer/utils/storage.py
async def download(self, asset: Asset, directory: Path | None = None) -> Path:
    """Make a local copy of `asset` and return its path.

    The copy lives at `<local_dir>/<cid>/<name>`; with `directory` it is
    also linked into that directory (how companions land beside their
    asset), replacing a different file of the same name there. An asset
    with no CID is a local file that was never stored: it is returned in
    place. An asset with a CID but no file is located through the index,
    or failing that the public IPFS gateway, and its `path` is set to
    what was found.

    Raises:
        FileNotFoundError: when the asset has neither a file nor a CID, or
            no CID and its local file doesn't exist
    """
    if asset.path is None:
        if not asset.cid:
            raise FileNotFoundError("Asset has neither a file nor a CID")
        row = await self.index.get(asset.cid)
        if row is not None:
            asset.path = File(path=row["uri"], name=row["name"], hash=asset.cid)
        else:
            asset.path = File(
                path=self.gateway_uri(asset.cid), name=asset.cid, hash=asset.cid
            )
    source = asset.path.path
    if not asset.cid:
        local = Path(source)
        if not local.exists():
            raise FileNotFoundError(f"{source} doesn't exist and has no CID")
        return local

    name = asset.path.name or source.rstrip("/").rsplit("/", 1)[-1]
    own = self._cache_path(asset.cid, name)
    if not own.exists():
        if Path(source).exists():
            _materialize(Path(source), own)
        else:
            own.parent.mkdir(parents=True, exist_ok=True)
            part = own.with_name(own.name + ".part")
            if source.startswith(("http://", "https://")):
                await _fetch_http(source, part)
            else:
                await flyte.storage.get(source, str(part))
            part.rename(own)
    if directory is None:
        return own
    target = Path(directory) / name
    # The directory is named for another asset's CID, so a file already
    # at `target` may be a different companion of the same name, linked
    # by an earlier fetch.
    if target.exists() and not target.samefile(own):
        target.unlink(missing_ok=True)
    _materialize(own, target)
    return target

gateway_uri(cid)

Where the public IPFS gateway serves cid.

Source code in src/stargazer/utils/storage.py
def gateway_uri(self, cid: str) -> str:
    """Where the public IPFS gateway serves `cid`."""
    return f"{self.gateway}/ipfs/{cid}"

publish(cid) async

Put one of the user's stored assets on the public tier, same CID.

Uploads the stored file to Pinata's public network with its index row's keyvalues; _owner is restamped from STARGAZER_OWNER, as on any upload, and files over 100 MB go up in resumable chunks. The user's row stays, so the asset is in both tiers, and query keeps the row over the public duplicate.

Parameters:

Name Type Description Default
cid str

The stored asset to publish

required

Returns:

Type Description
dict

The public record: cid, name and keyvalues

Raises:

Type Description
ValueError

with no public tier, for a CID that isn't in the user's index, for keyvalues over Pinata's limits (before anything is sent), or when Pinata assigns a different CID

Source code in src/stargazer/utils/storage.py
async def publish(self, cid: str) -> dict:
    """Put one of the user's stored assets on the public tier, same CID.

    Uploads the stored file to Pinata's public network with its index
    row's keyvalues; `_owner` is restamped from `STARGAZER_OWNER`, as on
    any upload, and files over 100 MB go up in resumable chunks. The
    user's row stays, so the asset is in both tiers, and `query` keeps
    the row over the public duplicate.

    Args:
        cid: The stored asset to publish

    Returns:
        The public record: `cid`, `name` and `keyvalues`

    Raises:
        ValueError: with no public tier, for a CID that isn't in the
            user's index, for keyvalues over Pinata's limits (before
            anything is sent), or when Pinata assigns a different CID
    """
    if self.public is None:
        raise ValueError("No PINATA_JWT: there's no public tier to publish to")
    row = await self.index.get(cid)
    if row is None:
        raise ValueError(f"{cid} isn't in your storage")
    stored = Asset(cid=cid, path=File(path=row["uri"], name=row["name"], hash=cid))
    local = await self.download(stored)
    published = Asset(keyvalues=_stamp_owner(dict(row["keyvalues"])))
    await self.public.upload(published, local)
    # Our CIDs are computed to match Pinata's, so a mismatch is a bug.
    if published.cid != cid:
        raise ValueError(
            f"Pinata stored {cid} as {published.cid}; the CIDs should match"
        )
    return {
        "cid": cid,
        "name": row["name"],
        "keyvalues": published.to_keyvalues(),
    }

query(filters) async

Records matching every filter: the user's rows, then public-only ones.

Parameters:

Name Type Description Default
filters dict

Keyvalue filters; a list value matches any of its entries

required

Returns:

Type Description
list[dict]

Rows with cid, uri, name and keyvalues

Source code in src/stargazer/utils/storage.py
async def query(self, filters: dict) -> list[dict]:
    """Records matching every filter: the user's rows, then public-only ones.

    Args:
        filters: Keyvalue filters; a list value matches any of its entries

    Returns:
        Rows with `cid`, `uri`, `name` and `keyvalues`
    """
    rows = await self.index.query(filters)
    if self.public is None:
        return rows
    mine = {r["cid"] for r in rows}
    public: dict[str, dict] = {}
    for combo in generate_query_combinations(filters):
        for record in await self.public.query(combo):
            if record["cid"] not in mine:
                public.setdefault(record["cid"], self._public_record(record))
    return [*rows, *public.values()]

staging_uri(upload_id, name)

Where a file uploaded in pieces is written, before its CID is known.

Source code in src/stargazer/utils/storage.py
def staging_uri(self, upload_id: str, name: str) -> str:
    """Where a file uploaded in pieces is written, before its CID is known."""
    return f"{self.store_root}/users/{_owner()}/uploads/{upload_id}/{name}"

update_metadata(cid, keyvalues) async

Merge a metadata patch onto the user's index row, restamping _owner.

Parameters:

Name Type Description Default
cid str

The record to update

required
keyvalues dict[str, str]

Keys to add or overwrite; others are kept

required

Raises:

Type Description
ValueError

when the user has no row for cid

Source code in src/stargazer/utils/storage.py
async def update_metadata(self, cid: str, keyvalues: dict[str, str]) -> dict:
    """Merge a metadata patch onto the user's index row, restamping `_owner`.

    Args:
        cid: The record to update
        keyvalues: Keys to add or overwrite; others are kept

    Raises:
        ValueError: when the user has no row for `cid`
    """
    return await self.index.merge(cid, _stamp_owner(dict(keyvalues)))

upload(asset, path) async

Store path as asset: bytes to the store, then the index row.

Sets asset.cid and asset.path (a File at the stored location) and seeds the local cache. Bytes the CID's row already points at, or already at the target path, aren't transferred again. Whatever the store or the index raises propagates: a row that doesn't land fails the upload.

Source code in src/stargazer/utils/storage.py
async def upload(self, asset: Asset, path: Path) -> None:
    """Store `path` as `asset`: bytes to the store, then the index row.

    Sets `asset.cid` and `asset.path` (a File at the stored location) and
    seeds the local cache. Bytes the CID's row already points at, or
    already at the target path, aren't transferred again. Whatever the
    store or the index raises propagates: a row that doesn't land fails
    the upload.
    """
    path = Path(path)
    cid = await asyncio.to_thread(compute_cid, path)
    uri, name = await self._stored_copy(cid)
    if uri is None:
        uri, name = (
            f"{self.store_root}/users/{_owner()}/assets/{cid}/{path.name}",
            path.name,
        )
        if not await flyte.storage.exists(uri):
            await asyncio.to_thread(_makedirs, uri.rsplit("/", 1)[0])
            await flyte.storage.put(str(path), uri)
    await self._index_as(asset, cid, uri, name)
    _materialize(path, self._cache_path(cid, name))

get_client()

Build a storage client from the environment.

STARGAZER_STORE_ROOT, STARGAZER_INDEX_URL and STARGAZER_LOCAL pick the store, the index and the cache; PINATA_JWT turns on the public tier.

Source code in src/stargazer/utils/storage.py
def get_client() -> StorageClient:
    """Build a storage client from the environment.

    `STARGAZER_STORE_ROOT`, `STARGAZER_INDEX_URL` and `STARGAZER_LOCAL` pick
    the store, the index and the cache; `PINATA_JWT` turns on the public
    tier.
    """
    return StorageClient(
        local_dir=Path(os.environ["STARGAZER_LOCAL"]),
        store_root=os.environ["STARGAZER_STORE_ROOT"],
        index=make_index(os.environ["STARGAZER_INDEX_URL"]),
        public=PinataClient() if os.environ.get("PINATA_JWT") else None,
    )

Subprocess utilities for running external commands.

spec: docs/architecture/tasks.md

Workflows

GATK Best Practices: Data Pre-processing for Variant Discovery

Implements: 1. Reference preparation — FASTA index, sequence dictionary, BWA-MEM2 index 2. Sample preprocessing — align, sort, mark duplicates

References

spec: docs/architecture/workflows.md

prepare_reference(build) async

Prepare reference genome for alignment and variant calling.

Assembles the reference FASTA from storage and creates its indices: 1. FASTA index (samtools faidx) 2. Sequence dictionary (GATK CreateSequenceDictionary) 3. BWA-MEM2 index (bwa-mem2 index)

Each is stored as a companion of the reference, so fetching the reference brings them along.

Parameters:

Name Type Description Default
build str

Reference genome build identifier (e.g. "GRCh38")

required

Returns:

Type Description
Reference

Reference asset (FASTA file)

Source code in src/stargazer/workflows/gatk_data_preprocessing.py
@gatk_env.task
async def prepare_reference(build: str) -> Reference:
    """
    Prepare reference genome for alignment and variant calling.

    Assembles the reference FASTA from storage and creates its indices:
    1. FASTA index (samtools faidx)
    2. Sequence dictionary (GATK CreateSequenceDictionary)
    3. BWA-MEM2 index (bwa-mem2 index)

    Each is stored as a companion of the reference, so fetching the
    reference brings them along.

    Args:
        build: Reference genome build identifier (e.g. "GRCh38")

    Returns:
        Reference asset (FASTA file)
    """
    log_execution()
    assets = await assemble(build=build, asset="reference")
    refs = [a for a in assets if isinstance(a, Reference)]
    if not refs:
        raise ValueError(f"No reference found for build={build!r}")
    ref = refs[0]

    await samtools_faidx(ref)
    await create_sequence_dictionary(ref)
    await bwa_mem2_index(ref)

    return ref

preprocess_sample(build, sample_id) async

Pre-process a single sample's reads for variant calling.

Assembles reference and reads from storage, then runs: 1. BWA-MEM2 alignment 2. Coordinate sort (GATK SortSam) 3. Mark duplicates (GATK MarkDuplicates)

Parameters:

Name Type Description Default
build str

Reference genome build identifier

required
sample_id str

Sample identifier used to query reads

required

Returns:

Type Description
Alignment

Alignment asset with the preprocessed BAM file

Source code in src/stargazer/workflows/gatk_data_preprocessing.py
@gatk_env.task
async def preprocess_sample(
    build: str,
    sample_id: str,
) -> Alignment:
    """
    Pre-process a single sample's reads for variant calling.

    Assembles reference and reads from storage, then runs:
    1. BWA-MEM2 alignment
    2. Coordinate sort (GATK SortSam)
    3. Mark duplicates (GATK MarkDuplicates)

    Args:
        build: Reference genome build identifier
        sample_id: Sample identifier used to query reads

    Returns:
        Alignment asset with the preprocessed BAM file
    """
    log_execution()
    ref_assets = await assemble(build=build, asset="reference")
    refs = [a for a in ref_assets if isinstance(a, Reference)]
    if not refs:
        raise ValueError(f"No reference found for build={build!r}")
    ref = refs[0]

    read_assets = await assemble(sample_id=sample_id, asset=["r1", "r2"])
    r1_list = [a for a in read_assets if isinstance(a, R1)]
    if not r1_list:
        raise ValueError(f"No R1 reads found for sample_id={sample_id!r}")
    r1 = r1_list[0]
    r2_list = [a for a in read_assets if isinstance(a, R2)]
    r2 = r2_list[0] if r2_list else None

    alignment = await bwa_mem2_mem(ref=ref, r1=r1, r2=r2)
    alignment = await sort_sam(alignment=alignment, sort_order="coordinate")
    alignment = await mark_duplicates(alignment=alignment)

    return alignment

GATK Best Practices: Germline Short Variant Discovery (SNPs + Indels)

End-to-end GATK pipeline from raw reads to joint-genotyped variants
  1. prepare_reference — FASTA index, sequence dictionary, BWA-MEM2 index
  2. preprocess_sample — align, sort, mark duplicates (per sample, parallel)
  3. haplotype_caller — per-sample GVCF (parallel)
  4. joint_call_gvcfs — GenomicsDBImport + GenotypeGVCFs
Reference

https://gatk.broadinstitute.org/hc/en-us/articles/360035535932-Germline-short-variant-discovery-SNPs-Indels

spec: docs/architecture/workflows.md

germline_short_variant_discovery(build, sample_ids, cohort_id='cohort') async

End-to-end germline short variant discovery from raw reads.

Runs the full GATK best-practices pipeline: 1. Reference preparation (indexing) 2. Per-sample preprocessing (align, sort, mark duplicates) in parallel 3. HaplotypeCaller per sample in parallel 4. Joint genotyping (GenomicsDBImport + GenotypeGVCFs)

Parameters:

Name Type Description Default
build str

Reference genome build identifier (e.g. "GRCh38")

required
sample_ids list[str]

List of sample identifiers to process

required
cohort_id str

Identifier for the cohort output (default: "cohort")

'cohort'

Returns:

Type Description
Variants

Joint-genotyped Variants asset

Source code in src/stargazer/workflows/germline_short_variant_discovery.py
@gatk_env.task(cache="disable")
async def germline_short_variant_discovery(
    build: str,
    sample_ids: list[str],
    cohort_id: str = "cohort",
) -> Variants:
    """
    End-to-end germline short variant discovery from raw reads.

    Runs the full GATK best-practices pipeline:
    1. Reference preparation (indexing)
    2. Per-sample preprocessing (align, sort, mark duplicates) in parallel
    3. HaplotypeCaller per sample in parallel
    4. Joint genotyping (GenomicsDBImport + GenotypeGVCFs)

    Args:
        build: Reference genome build identifier (e.g. "GRCh38")
        sample_ids: List of sample identifiers to process
        cohort_id: Identifier for the cohort output (default: "cohort")

    Returns:
        Joint-genotyped Variants asset
    """
    log_execution()

    ref = await prepare_reference(build=build)

    alignments = list(
        await asyncio.gather(
            *[preprocess_sample(build=build, sample_id=sid) for sid in sample_ids]
        )
    )

    gvcfs = list(
        await asyncio.gather(
            *[haplotype_caller(alignment=aln, ref=ref) for aln in alignments]
        )
    )

    # Joint calling covers every contig: the task reads them from the
    # reference's .fai, which prepare_reference built.
    return await joint_call_gvcfs(gvcfs=gvcfs, ref=ref, cohort_id=cohort_id)

scRNA-seq clustering pipeline: QC → normalization → clustering → marker genes.

Implements the scanpy clustering tutorial workflow as Flyte v2 tasks. Assembles a raw AnnData from storage by sample_id, then runs the full preprocessing and clustering stack.

Prerequisites

A raw .h5ad file must be uploaded to storage with asset="anndata", stage="raw" and organism set to "human" or "mouse", which picks the mitochondrial, ribosomal and hemoglobin genes QC filters on.

Reference

https://scanpy.readthedocs.io/en/stable/tutorials/basics/clustering.html

spec: docs/architecture/workflows.md

scrna_clustering_pipeline(sample_id, n_top_genes=2000, resolution=0.5, max_pct_mt=20.0) async

End-to-end scRNA-seq clustering pipeline.

Runs QC filtering, normalization, feature selection, dimensionality reduction, Leiden clustering, and marker gene identification in sequence.

Parameters:

Name Type Description Default
sample_id str

Sample identifier used to look up the raw AnnData in storage

required
n_top_genes int

Number of highly variable genes to select

2000
resolution float

Leiden clustering resolution (higher = more clusters)

0.5
max_pct_mt float

Maximum mitochondrial gene percentage per cell

20.0

Returns:

Type Description
AnnData

Annotated AnnData asset with cluster labels and ranked marker genes

Source code in src/stargazer/workflows/scrna_clustering.py
@scrna_env.task(cache="disable")
async def scrna_clustering_pipeline(
    sample_id: str,
    n_top_genes: int = 2000,
    resolution: float = 0.5,
    max_pct_mt: float = 20.0,
) -> AnnData:
    """End-to-end scRNA-seq clustering pipeline.

    Runs QC filtering, normalization, feature selection, dimensionality
    reduction, Leiden clustering, and marker gene identification in sequence.

    Args:
        sample_id: Sample identifier used to look up the raw AnnData in storage
        n_top_genes: Number of highly variable genes to select
        resolution: Leiden clustering resolution (higher = more clusters)
        max_pct_mt: Maximum mitochondrial gene percentage per cell

    Returns:
        Annotated AnnData asset with cluster labels and ranked marker genes
    """
    log_execution()

    assets = await assemble(sample_id=sample_id, asset="anndata", stage="raw")
    if not assets:
        raise ValueError(
            f"No raw AnnData found for sample_id={sample_id!r}. "
            "Upload a .h5ad file with asset='anndata', stage='raw' and its "
            "organism first."
        )
    raw = assets[0]

    filtered = await qc_filter(adata=raw, max_pct_mt=max_pct_mt)
    normalized = await normalize(adata=filtered)
    featured = await select_features(adata=normalized, n_top_genes=n_top_genes)
    reduced = await reduce_dimensions(adata=featured)
    clustered = await cluster(adata=reduced, resolution=resolution)
    annotated = await find_markers(adata=clustered)

    return annotated