Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.16.1"
".": "0.17.0"
}
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# Changelog

## 0.17.0 (2026-08-22)


### ⚠ BREAKING CHANGES

* **molecules:** Complex no longer sorts chains by ID. Chains iterate and serialize in insertion order, so code relying on get_chains() returning alphabetical order must sort explicitly. Combining with & or | appends the new chain last rather than sorting it into place, and both now return a new Complex instead of mutating the left operand. Complex is also no longer hashable, matching its mutable-mapping semantics. Structures parsed from CIF or PDB are unaffected.

### Features

* expose unnumbered_policy and job warnings on alignments
* **prompt:** curated antibody prompts (VH, VL, VH-VL, VL-VH) as attributes on the prompt API
* **molecules:** dict-like Complex interface with insertion-ordered chains


## 0.16.2 (2026-07-20)


### Bug Fixes

* export single site future

## 0.16.1 (2026-07-14)


Expand Down
17 changes: 7 additions & 10 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
description = "A basic flake with a shell";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
inputs.nixpkgs.url = "nixpkgs";
inputs.systems.url = "github:nix-systems/default";
inputs.flake-utils = {
url = "github:numtide/flake-utils";
Expand Down
20 changes: 18 additions & 2 deletions openprotein/align/align.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from . import api
from .msa import MSAFuture
from .schemas import AbNumberScheme, AlignType
from .schemas import AbNumberScheme, AlignType, UnnumberedPolicy


class AlignAPI:
Expand Down Expand Up @@ -190,6 +190,7 @@ def abnumber(
names: Sequence[str] | None = None,
scheme: AbNumberScheme = AbNumberScheme.CHOTHIA,
drop_minority_chains: bool = False,
unnumbered_policy: UnnumberedPolicy = UnnumberedPolicy.ERROR,
) -> MSAFuture:
"""
Align antibody sequences using `AbNumber`.
Expand All @@ -210,6 +211,11 @@ def abnumber(
If True, drop sequences belonging to chain types that are in the
minority (e.g. heavy vs light) so the resulting alignment contains
only the dominant chain type.
unnumbered_policy : UnnumberedPolicy, default=UnnumberedPolicy.ERROR
What to do with residues outside the numbered variable domain --
a constant/Fc region, an N-terminal tag. ERROR fails the job, DROP
aligns the numbered residues only, RETAIN keeps them padded against
the numbered-domain boundary.

Returns
-------
Expand Down Expand Up @@ -241,14 +247,18 @@ def abnumber(
content = b"\n".join(lines)
stream = BytesIO(content)
return self.abnumber_file(
stream, scheme=scheme, drop_minority_chains=drop_minority_chains
stream,
scheme=scheme,
drop_minority_chains=drop_minority_chains,
unnumbered_policy=unnumbered_policy,
)

def abnumber_file(
self,
file,
scheme: AbNumberScheme = AbNumberScheme.CHOTHIA,
drop_minority_chains: bool = False,
unnumbered_policy: UnnumberedPolicy = UnnumberedPolicy.ERROR,
) -> MSAFuture:
"""
Align antibody sequences using `AbNumber`.
Expand All @@ -267,6 +277,11 @@ def abnumber_file(
If True, drop sequences belonging to chain types that are in the
minority (e.g. heavy vs light) so the resulting alignment contains
only the dominant chain type.
unnumbered_policy : UnnumberedPolicy, default=UnnumberedPolicy.ERROR
What to do with residues outside the numbered variable domain --
a constant/Fc region, an N-terminal tag. ERROR fails the job, DROP
aligns the numbered residues only, RETAIN keeps them padded against
the numbered-domain boundary.

Returns
-------
Expand All @@ -278,6 +293,7 @@ def abnumber_file(
file,
scheme=scheme,
drop_minority_chains=drop_minority_chains,
unnumbered_policy=unnumbered_policy,
)
return MSAFuture.create(session=self.session, job=job)

Expand Down
37 changes: 28 additions & 9 deletions openprotein/align/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
from openprotein.errors import APIError, InvalidParameterError, MissingParameterError
from openprotein.jobs import Job

from .schemas import AbNumberScheme, AlignType, MSASamplingMethod
from .schemas import (
AbNumberScheme,
AlignType,
MSASamplingMethod,
UnnumberedPolicy,
)


def get_align_job_inputs(
Expand Down Expand Up @@ -270,6 +275,7 @@ def abnumber_post(
sequence_file: BinaryIO,
scheme: AbNumberScheme | str = AbNumberScheme.IMGT,
drop_minority_chains: bool = False,
unnumbered_policy: UnnumberedPolicy | str = UnnumberedPolicy.ERROR,
) -> Job:
"""
Align antibody sequences using AbNumber.
Expand All @@ -289,6 +295,9 @@ def abnumber_post(
If True, drop sequences belonging to chain types that are in the
minority (e.g. heavy vs light) so the resulting alignment contains
only the dominant chain type. Default is False.
unnumbered_policy : UnnumberedPolicy, optional
What to do with residues outside the numbered variable domain.
Default is ERROR, which fails the job.

Returns
-------
Expand All @@ -300,18 +309,28 @@ def abnumber_post(
if isinstance(scheme, str):
if scheme not in {value.value for value in AbNumberScheme}:
raise InvalidParameterError(f"Antibody numbering {scheme} not recognized")
if isinstance(unnumbered_policy, str):
if unnumbered_policy not in {value.value for value in UnnumberedPolicy}:
raise InvalidParameterError(
f"Unnumbered residue policy {unnumbered_policy} not recognized"
)

files = {"file": sequence_file}
params = {
"scheme": scheme if isinstance(scheme, str) else scheme.value,
"drop_minority_chains": drop_minority_chains,
"unnumbered_policy": (
unnumbered_policy
if isinstance(unnumbered_policy, str)
else unnumbered_policy.value
),
}

response = session.post(endpoint, files=files, params=params)
return Job.model_validate(response.json())


def antibody_schema_get(session: APISession, job_id: str):
def antibody_schema_get(session: APISession, job_id: str) -> dict:
"""
Retrieve the antibody numbering for an AbNumber job.

Expand All @@ -322,16 +341,16 @@ def antibody_schema_get(session: APISession, job_id: str):
job_id : str
The job identifier.

Raises
------
NotImplementedError
This function is not yet implemented.

Returns
-------
None
dict
The job's extras: a ``chains`` entry per chain position carrying the
numbering and CDR regions, and a ``warnings`` list.
"""
raise NotImplementedError()
endpoint = "v1/align/antibody_schema"

response = session.get(endpoint, params={"job_id": job_id})
return response.json()


def prompt_post(
Expand Down
49 changes: 49 additions & 0 deletions openprotein/align/msa.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""MSA results represented as a future."""

import warnings
from typing import Iterator

from openprotein import config
Expand All @@ -12,6 +13,7 @@
from .schemas import (
AbNumberJob,
ClustalOJob,
JobWarning,
MafftJob,
MSAJob,
MSASamplingMethod,
Expand Down Expand Up @@ -44,6 +46,7 @@ def __init__(
super().__init__(session, job)
self.page_size = page_size
self.msa_id = self.job.job_id
self._warned = False

def _get(self, verbose: bool = False) -> Iterator[tuple[str, str]]:
"""
Expand All @@ -59,8 +62,54 @@ def _get(self, verbose: bool = False) -> Iterator[tuple[str, str]]:
Iterator[tuple[str, str]]
An iterator over names and sequences of the MSA data.
"""
self._warn_once()
return api.get_msa(session=self.session, job_id=self.job.job_id)

def _warn_once(self) -> None:
"""Surface what the job reported about its own output, once."""
if self._warned or not isinstance(self.job, AbNumberJob):
return
self._warned = True
try:
job_warnings = self.get_warnings()
except Exception:
# reading the results cannot depend on the schema endpoint answering
return
for job_warning in job_warnings:
warnings.warn(f"{job_warning.code}: {job_warning.message}")

def get_antibody_schema(self) -> dict:
"""
Retrieve the antibody numbering for an AbNumber job.

Returns
-------
dict
One ``chains`` entry per chain position, carrying that position's
numbering, CDR regions and chain type, plus a ``warnings`` list.

Raises
------
HTTPError
If the job did not use AbNumber.
"""
return api.antibody_schema_get(session=self.session, job_id=self.job.job_id)

def get_warnings(self) -> list[JobWarning]:
"""
Retrieve what the job reported about its own output.

A job that succeeds can still have changed the data, e.g. by dropping
residues AbNumber could not number.

Returns
-------
list[JobWarning]
Empty if the job had nothing to report.
"""
schema = self.get_antibody_schema()
return [JobWarning.model_validate(w) for w in schema.get("warnings", [])]

def sample_prompt(
self,
num_sequences: int | None = None,
Expand Down
44 changes: 44 additions & 0 deletions openprotein/align/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,47 @@ class AbNumberScheme(str, Enum):
CHOTHIA = "chothia"
KABAT = "kabat"
AHO = "aho"


class UnnumberedPolicy(str, Enum):
"""
What to do with residues the numbering scheme gives no antibody position.

ANARCI numbers one variable domain per sequence. Residues outside it -- a
constant/Fc region, an N-terminal tag -- have no antibody position and
cannot share the numbering coordinate system.

Attributes
----------
ERROR : str
Fail the job if any sequence carries unnumbered residues.
DROP : str
Align the numbered residues only, discarding the rest.
RETAIN : str
Keep the unnumbered residues, padded against the numbered-domain
boundary. They are preserved, not aligned to each other.
"""

ERROR = "error"
DROP = "drop"
RETAIN = "retain"


class JobWarning(BaseModel):
"""
Something a job reports about its own output while still succeeding.

Attributes
----------
code : str
Machine-readable identifier, e.g. ``UNNUMBERED_RESIDUES_DROPPED``.
message : str
Human-readable description.
metadata : dict or None
Code-specific detail. For the unnumbered-residue codes this carries a
``chains`` list, one entry per affected chain position.
"""

code: str
message: str
metadata: dict | None = None
1 change: 1 addition & 0 deletions openprotein/embeddings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@
EmbeddingsGenerateFuture,
EmbeddingsResultFuture,
EmbeddingsScoreFuture,
EmbeddingsScoreSingleSiteFuture,
)
6 changes: 6 additions & 0 deletions openprotein/molecules/chains.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ def __len__(self):
def copy(self) -> "DNA":
return replace(self)

# TODO - support (&) and (|) operators for combining into Complexes (like Protein)

@staticmethod
def _from_structure_block(
structure_block: _cif_utils.StructureCIFBlock, chain_id: str, model_idx: int
Expand Down Expand Up @@ -159,6 +161,8 @@ def __len__(self):
def copy(self) -> "RNA":
return replace(self)

# TODO - support (&) and (|) operators for combining into Complexes (like Protein)

@staticmethod
def _from_structure_block(
structure_block: _cif_utils.StructureCIFBlock, chain_id: str, model_idx: int
Expand Down Expand Up @@ -210,6 +214,8 @@ def __post_init__(self):

def copy(self) -> "Ligand":
return replace(self)

# TODO - support (&) and (|) operators for combining into Complexes (like Protein)

@staticmethod
def _from_structure_block(
Expand Down
Loading
Loading