diff --git a/Makefile b/Makefile index a236cd8..a7c7376 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,14 @@ -VERSION ?= 0.4.0 +VERSION ?= 0.5.0 SHELL := /bin/bash .PHONY: releasehere releasegit: - # Update Poetry version - poetry version $(VERSION) + # Update Pixi version + pixi project version set $(VERSION) # Update version in meta.yaml - sed -i 's/^ version: .*/ version: "$(VERSION)"/' anaconda_build/meta.yaml + # sed -i'' 's/^ version: .*/^ version: "$(VERSION)"/' anaconda_build/meta.yaml + perl -i -pe's/^ version: .*/ version: "$(VERSION)"/' anaconda_build/meta.yaml # Commit changes git add pyproject.toml anaconda_build/meta.yaml @@ -20,8 +21,6 @@ releasegit: git push origin HEAD git push origin v$(VERSION) - curl -X PURGE https://camo.githubusercontent.com/125a275204c801f733fd69689c1e72bde9960a1e193c9c46299d848373a52a93/68747470733a2f2f616e61636f6e64612e6f72672f6f70656e70726f7465696e2f6f70656e70726f7465696e2d707974686f6e2f6261646765732f76657273696f6e2e737667 - releasehere: # Update Poetry version @@ -47,8 +46,10 @@ releasehere: #conda source activate bld && conda build ./anaconda_build - - curl -X PURGE https://camo.githubusercontent.com/125a275204c801f733fd69689c1e72bde9960a1e193c9c46299d848373a52a93/68747470733a2f2f616e61636f6e64612e6f72672f6f70656e70726f7465696e2f6f70656e70726f7465696e2d707974686f6e2f6261646765732f76657273696f6e2e737667 + +condabld: + micromamba activate openprotein-sdk-build + conda build -c conda-forge --numpy 2.1 ./anaconda_build proddocs: cd apidocs && make clean && make html diff --git a/anaconda_build/conda_build_config.yaml b/anaconda_build/conda_build_config.yaml new file mode 100644 index 0000000..e66baa8 --- /dev/null +++ b/anaconda_build/conda_build_config.yaml @@ -0,0 +1,2 @@ +numpy: + - 2.1 diff --git a/anaconda_build/meta.yaml b/anaconda_build/meta.yaml index f12e82f..56806e0 100644 --- a/anaconda_build/meta.yaml +++ b/anaconda_build/meta.yaml @@ -1,6 +1,6 @@ package: name: openprotein-python - version: "0.4.1" + version: "0.5.0" source: path: ../ @@ -12,18 +12,19 @@ build: requirements: build: - - python >=3.7 - - poetry + - python >=3.10,<3.11 + - hatchling >=1.25.0,<2 host: - - python >=3.7 + - python >=3.10,<3.11 - pip - - poetry + - hatchling >=1.25.0,<2 run: - - python >=3.7 - - requests >=2.0 - - pydantic >=1.0 - - tqdm >=4.0 - - pandas >=1.0 + - python >=3.10,<3.11 + - requests >=2.32.3,<3 + - pydantic >=2.5,<3 + - tqdm >=4.66.5,<5 + - pandas >=2.1.4,<3 + - numpy >=2.1.1,<3 about: home: https://www.openprotein.ai/ diff --git a/openprotein/__init__.py b/openprotein/__init__.py index b2d6d08..b74045c 100644 --- a/openprotein/__init__.py +++ b/openprotein/__init__.py @@ -1,14 +1,17 @@ from openprotein._version import __version__ - +from openprotein.app import ( + SVDAPI, + AlignAPI, + AssayDataAPI, + DesignAPI, + EmbeddingsAPI, + FoldAPI, + JobsAPI, + PredictorAPI, + TrainingAPI, +) +from openprotein.app.models import Future from openprotein.base import APISession -from openprotein.api.jobs import JobsAPI, Job -from openprotein.api.data import DataAPI -from openprotein.api.align import AlignAPI -from openprotein.api.embedding import EmbeddingAPI -from openprotein.api.train import TrainingAPI -from openprotein.api.design import DesignAPI -from openprotein.api.fold import FoldAPI -from openprotein.api.jobs import load_job class OpenProtein(APISession): @@ -17,20 +20,22 @@ class OpenProtein(APISession): """ _embedding = None + _svd = None _fold = None _align = None _jobs = None _data = None _train = None _design = None + _predictor = None - def wait(self, job: Job, *args, **kwargs): - return job.wait(self, *args, **kwargs) + def wait(self, future: Future, *args, **kwargs): + return future.wait(*args, **kwargs) wait_until_done = wait def load_job(self, job_id): - return load_job(self, job_id) + return self.jobs.__load(job_id=job_id) @property def jobs(self) -> JobsAPI: @@ -42,12 +47,12 @@ def jobs(self) -> JobsAPI: return self._jobs @property - def data(self) -> DataAPI: + def data(self) -> AssayDataAPI: """ The data submodule gives access to functionality for uploading and accessing user data. """ if self._data is None: - self._data = DataAPI(self) + self._data = AssayDataAPI(self) return self._data @property @@ -62,21 +67,39 @@ def train(self) -> TrainingAPI: @property def align(self) -> AlignAPI: """ - The PoET submodule gives access to the PoET generative model and MSA and prompt creation interfaces. + The Align submodule gives access to the sequence alignment capabilities by building MSAs and prompts that can be used with PoET. """ if self._align is None: self._align = AlignAPI(self) return self._align @property - def embedding(self) -> EmbeddingAPI: + def embedding(self) -> EmbeddingsAPI: """ The embedding submodule gives access to protein embedding models and their inference endpoints. """ if self._embedding is None: - self._embedding = EmbeddingAPI(self) + self._embedding = EmbeddingsAPI(self) return self._embedding + @property + def svd(self) -> SVDAPI: + """ + The embedding submodule gives access to protein embedding models and their inference endpoints. + """ + if self._svd is None: + self._svd = SVDAPI(self, self.embedding) + return self._svd + + @property + def predictor(self) -> PredictorAPI: + """ + The predictor submodule gives access to training and predicting with predictors built on top of embeddings. + """ + if self._predictor is None: + self._predictor = PredictorAPI(self, self.embedding, self.svd) + return self._predictor + @property def design(self) -> DesignAPI: """ diff --git a/openprotein/api/__init__.py b/openprotein/api/__init__.py index 558c3ff..c53bffd 100644 --- a/openprotein/api/__init__.py +++ b/openprotein/api/__init__.py @@ -1,7 +1,2 @@ -from . import data -from . import design -from . import train -from . import predict -from . import align -from . import embedding -from . import fold \ No newline at end of file +from . import align, assaydata, design, embedding, fold, predict, predictor, train +from .deprecated import poet diff --git a/openprotein/api/align.py b/openprotein/api/align.py index 1772d01..14f467e 100644 --- a/openprotein/api/align.py +++ b/openprotein/api/align.py @@ -1,87 +1,17 @@ -from typing import Iterator, Optional, List, BinaryIO, Literal, Union -from openprotein.pydantic import BaseModel, Field, validator, root_validator -from enum import Enum -from io import BytesIO -import random -import csv import codecs -import requests - -from openprotein.base import APISession -from openprotein.api.jobs import ( - AsyncJobFuture, -) - -from openprotein.jobs import ( - ResultsParser, - Job, - register_job_type, - JobType, - job_args_get, -) +import csv +import io +import random +from typing import BinaryIO, Iterator import openprotein.config as config - -from openprotein.errors import ( - InvalidParameterError, - MissingParameterError, - APIError, -) -from openprotein.futures import FutureBase, FutureFactory - - -class PoetInputType(str, Enum): - INPUT = "RAW" - MSA = "GENERATED" - PROMPT = "PROMPT" - - -class MSASamplingMethod(str, Enum): - RANDOM = "RANDOM" - NEIGHBORS = "NEIGHBORS" - NEIGHBORS_NO_LIMIT = "NEIGHBORS_NO_LIMIT" - NEIGHBORS_NONGAP_NORM_NO_LIMIT = "NEIGHBORS_NONGAP_NORM_NO_LIMIT" - TOP = "TOP" - - -class PromptPostParams(BaseModel): - msa_id: str - num_sequences: Optional[int] = Field(None, ge=0, lt=100) - num_residues: Optional[int] = Field(None, ge=0, lt=24577) - method: MSASamplingMethod = MSASamplingMethod.NEIGHBORS_NONGAP_NORM_NO_LIMIT - homology_level: float = Field(0.8, ge=0, le=1) - max_similarity: float = Field(1.0, ge=0, le=1) - min_similarity: float = Field(0.0, ge=0, le=1) - always_include_seed_sequence: bool = False - num_ensemble_prompts: int = 1 - random_seed: Optional[int] = None - - -@register_job_type(JobType.align_align) -class MSAJob(Job): - msa_id: Optional[str] = None - job_type: Literal[JobType.align_align] = JobType.align_align - - @root_validator - def set_msa_id(cls, values): - if not values.get("msa_id"): - values["msa_id"] = values.get("job_id") - return values - - -@register_job_type(JobType.align_prompt) -class PromptJob(MSAJob): - prompt_id: Optional[str] = None - job_type: Literal[JobType.align_prompt] = JobType.align_prompt - - @root_validator - def set_prompt_id(cls, values): - if not values.get("prompt_id"): - values["prompt_id"] = values.get("job_id") - return values +import requests +from openprotein.base import APISession +from openprotein.errors import APIError, InvalidParameterError, MissingParameterError +from openprotein.schemas import Job, MSASamplingMethod, PoetInputType -def csv_stream(response: requests.Response) -> csv.reader: +def csv_stream(response: requests.Response) -> Iterator[list[str]]: """ Returns a CSV reader from a requests.Response object. @@ -104,9 +34,9 @@ def csv_stream(response: requests.Response) -> csv.reader: def get_align_job_inputs( session: APISession, - job_id, + job_id: str, input_type: PoetInputType, - prompt_index: Optional[int] = None, + prompt_index: int | None = None, ) -> requests.Response: """ Get MSA and related data for an align job. @@ -142,17 +72,17 @@ def get_align_job_inputs( def get_input( - self: APISession, + session: APISession, job: Job, input_type: PoetInputType, - prompt_index: Optional[int] = None, -) -> csv.reader: + prompt_index: int | None = None, +) -> Iterator[list[str]]: """ Get input data for a given job. Parameters ---------- - self : APISession + session : APISession The API session. job : Job The job for which to retrieve data. @@ -167,19 +97,21 @@ def get_input( A CSV reader for the response data. """ job_id = job.job_id - response = get_align_job_inputs(self, job_id, input_type, prompt_index=prompt_index) + response = get_align_job_inputs( + session=session, job_id=job_id, input_type=input_type, prompt_index=prompt_index + ) return csv_stream(response) def get_prompt( - self: APISession, job: Job, prompt_index: Optional[int] = None -) -> csv.reader: + session: APISession, job: Job, prompt_index: int | None = None +) -> Iterator[list[str]]: """ Get the prompt for a given job. Parameters ---------- - self : APISession + session : APISession The API session. job : Job The job for which to retrieve the prompt. @@ -188,51 +120,60 @@ def get_prompt( Returns ------- - csv.reader + Iterator[list[str]] A CSV reader for the prompt data. """ - return get_input(self, job, PoetInputType.PROMPT, prompt_index=prompt_index) + return get_input( + session=session, + job=job, + input_type=PoetInputType.PROMPT, + prompt_index=prompt_index, + ) -def get_seed(self: APISession, job: Job) -> csv.reader: +def get_seed(session: APISession, job: Job) -> Iterator[list[str]]: """ Get the seed for a given MSA job. Parameters ---------- - self : APISession + session : APISession The API session. job : Job The job for which to retrieve the seed. Returns ------- - csv.reader + Iterator[list[str]] A CSV reader for the seed sequence. """ - return get_input(self, job, PoetInputType.INPUT) + return get_input(session=session, job=job, input_type=PoetInputType.INPUT) -def get_msa(self: APISession, job: Job) -> csv.reader: +def get_msa(session: APISession, job: Job) -> Iterator[list[str]]: """ Get the generated MSA (Multiple Sequence Alignment) for a given job. Parameters ---------- - self : APISession + session : APISession The API session. job : Job The job for which to retrieve the MSA. Returns ------- - csv.reader + Iterator[list[str]] A CSV reader for the MSA data. """ - return get_input(self, job, PoetInputType.MSA) + return get_input(session=session, job=job, input_type=PoetInputType.MSA) -def msa_post(session: APISession, msa_file=None, seed=None): +def msa_post( + session: APISession, + msa_file: BinaryIO | None = None, + seed: str | bytes | None = None, +) -> Job: """ Create an MSA. @@ -243,11 +184,11 @@ def msa_post(session: APISession, msa_file=None, seed=None): Parameters ---------- session : APISession - Authorized session. - msa_file : str, optional - Ready-made MSA. Defaults to None. - seed : str, optional - Seed to trigger MSA job. Defaults to None. + + msa_file : BinaryIO, Optional + Ready-made MSA file. Defaults to None. + seed : str | bytes, optional + Seed sequence to trigger MSA job. Defaults to None. Raises ------ @@ -256,7 +197,7 @@ def msa_post(session: APISession, msa_file=None, seed=None): Returns ------- - MSAJob + Job Job details. """ if (msa_file is None and seed is None) or ( @@ -267,29 +208,30 @@ def msa_post(session: APISession, msa_file=None, seed=None): is_seed = False if seed is not None: - msa_file = BytesIO(b"\n".join([b">seed", seed])) + seed = seed.encode() if isinstance(seed, str) else seed + msa_file = io.BytesIO(b"\n".join([b">seed", seed])) is_seed = True params = {"is_seed": is_seed} files = {"msa_file": msa_file} response = session.post(endpoint, files=files, params=params) - return FutureFactory.create_future(session=session, response=response) + return Job.model_validate(response.json()) def prompt_post( session: APISession, msa_id: str, - num_sequences: Optional[int] = None, - num_residues: Optional[int] = None, + num_sequences: int | None = None, + num_residues: int | None = None, method: MSASamplingMethod = MSASamplingMethod.NEIGHBORS_NONGAP_NORM_NO_LIMIT, homology_level: float = 0.8, max_similarity: float = 1.0, min_similarity: float = 0.0, always_include_seed_sequence: bool = False, num_ensemble_prompts: int = 1, - random_seed: Optional[int] = None, -) -> PromptJob: + random_seed: int | None = None, +) -> Job: """ Create a protein sequence prompt from a linked MSA (Multiple Sequence Alignment) for PoET Jobs. @@ -329,7 +271,7 @@ def prompt_post( Returns ------- - PromptJob + Job """ endpoint = "v1/align/prompt" @@ -375,7 +317,7 @@ def prompt_post( params["max_msa_tokens"] = num_residues response = session.post(endpoint, params=params) - return FutureFactory.create_future(session=session, response=response) + return Job.model_validate(response.json()) def upload_prompt_post( @@ -402,7 +344,7 @@ def upload_prompt_post( Returns ------- - PromptJob + Job An object representing the status and results of the prompt job. """ @@ -410,12 +352,14 @@ def upload_prompt_post( files = {"prompt_file": prompt_file} try: response = session.post(endpoint, files=files) - return FutureFactory.create_future(session=session, response=response) + return Job.model_validate(response.json()) except Exception as exc: raise APIError(f"Failed to upload prompt post: {exc}") from exc -def poet_score_post(session: APISession, prompt_id: str, queries: List[bytes]): +def poet_score_post( + session: APISession, prompt_id: str, queries: list[bytes | str] +) -> Job: """ Submits a job to score sequences based on the given prompt. @@ -435,7 +379,7 @@ def poet_score_post(session: APISession, prompt_id: str, queries: List[bytes]): Returns ------- - PoetScoreJob + Job An object representing the status and results of the scoring job. """ endpoint = "v1/poet/score" @@ -445,22 +389,21 @@ def poet_score_post(session: APISession, prompt_id: str, queries: List[bytes]): if not prompt_id: raise MissingParameterError("Must include prompt_id in request!") - if isinstance(queries[0], str): - queries = [i.encode() for i in queries] + queries_bytes = [i.encode() if isinstance(i, str) else i for i in queries] try: - variant_file = BytesIO(b"\n".join(queries)) + variant_file = io.BytesIO(b"\n".join(queries_bytes)) params = {"prompt_id": prompt_id} response = session.post( endpoint, files={"variant_file": variant_file}, params=params ) - return FutureFactory.create_future(session=session, response=response) + return Job.model_validate(response.json()) except Exception as exc: raise APIError(f"Failed to post poet score: {exc}") from exc def poet_score_get( session: APISession, job_id, page_size=config.POET_PAGE_SIZE, page_offset=0 -): +) -> Job: """ Fetch a page of results from a PoET score job. @@ -482,7 +425,7 @@ def poet_score_get( Returns ------- - PoetScoreJob + Job An object representing the PoET scoring job, including its current status and results (if any). """ endpoint = "v1/poet/score" @@ -497,375 +440,4 @@ def poet_score_get( params={"job_id": job_id, "page_size": page_size, "page_offset": page_offset}, ) - return FutureFactory.create_future(session=session, response=response) - - -class AlignFutureMixin: - session: APISession - job: Job - - def get_input(self, input_type: PoetInputType): - """See child function docs.""" - return get_input(self.session, self.job, input_type) - - def get_prompt(self, prompt_index: Optional[int] = None): - """See child function docs.""" - return get_prompt(self.session, self.job, prompt_index=prompt_index) - - def get_seed(self): - """See child function docs.""" - return get_seed(self.session, self.job) - - def get_msa(self): - """See child function docs.""" - return get_msa(self.session, self.job) - - @property - def id(self): - return self.job.job_id - - -class MSAFuture(AlignFutureMixin, AsyncJobFuture, FutureBase): - """ - Represents a result of a MSA job. - - Attributes - ---------- - session : APISession - An instance of APISession for API interactions. - job : Job - The PoET scoring job. - page_size : int - The number of results to fetch in a single page. - - Methods - ------- - get(verbose=False) - Get the final results of the PoET scoring job. - - Returns - ------- - List[PoetScoreResult] - The list of results from the PoET scoring job. - """ - - job_type = "/align/align" - - def __init__(self, session: APISession, job: Job, page_size=config.POET_PAGE_SIZE): - """ - init a PoetScoreFuture instance. - - Parameters - ---------- - session : APISession - An instance of APISession for API interactions. - job : Job - The PoET scoring job. - page_size : int - The number of results to fetch in a single page. - - """ - super().__init__(session, job) - self.page_size = page_size - self._msa_id = None - self._prompt_id = None - - def __str__(self) -> str: - return str(self.job) - - def __repr__(self) -> str: - return repr(self.job) - - @property - def id(self): - return self.job.job_id - - @property - def prompt_id(self): - if self.job.job_type == "/align/prompt" and self._prompt_id is None: - self._prompt_id = self.job.job_id - return self._prompt_id - - @property - def msa_id(self): - if self.job.job_type == "/align/align" and self._msa_id is None: - self._msa_id = self.job.job_id - return self._msa_id - - def wait(self, verbose: bool = False): - _ = self.job.wait( - self.session, - interval=config.POLLING_INTERVAL, - timeout=config.POLLING_TIMEOUT, - verbose=False, - ) # no progress to track - return self.get() - - def get(self, verbose: bool = False) -> csv.reader: - return self.get_msa() - - def sample_prompt( - self, - num_sequences: Optional[int] = None, - num_residues: Optional[int] = None, - method: MSASamplingMethod = MSASamplingMethod.NEIGHBORS_NONGAP_NORM_NO_LIMIT, - homology_level: float = 0.8, - max_similarity: float = 1.0, - min_similarity: float = 0.0, - always_include_seed_sequence: bool = False, - num_ensemble_prompts: int = 1, - random_seed: Optional[int] = None, - ) -> PromptJob: - """ - Create a protein sequence prompt from a linked MSA (Multiple Sequence Alignment) for PoET Jobs. - - Parameters - ---------- - num_sequences : int, optional - Maximum number of sequences in the prompt. Must be <100. - num_residues : int, optional - Maximum number of residues (tokens) in the prompt. Must be less than 24577. - method : MSASamplingMethod, optional - Method to use for MSA sampling. Defaults to NEIGHBORS_NONGAP_NORM_NO_LIMIT. - homology_level : float, optional - Level of homology for sequences in the MSA (neighbors methods only). Must be between 0 and 1. Defaults to 0.8. - max_similarity : float, optional - Maximum similarity between sequences in the MSA and the seed. Must be between 0 and 1. Defaults to 1.0. - min_similarity : float, optional - Minimum similarity between sequences in the MSA and the seed. Must be between 0 and 1. Defaults to 0.0. - always_include_seed_sequence : bool, optional - Whether to always include the seed sequence in the MSA. Defaults to False. - num_ensemble_prompts : int, optional - Number of ensemble jobs to run. Defaults to 1. - random_seed : int, optional - Seed for random number generation. Defaults to a random number between 0 and 2**32-1. - - Raises - ------ - InvalidParameterError - If provided parameter values are not in the allowed range. - MissingParameterError - If both or none of 'num_sequences', 'num_residues' is specified. - - Returns - ------- - PromptJob - """ - msa_id = self.msa_id - return prompt_post( - self.session, - msa_id, - num_sequences=num_sequences, - num_residues=num_residues, - method=method, - homology_level=homology_level, - max_similarity=max_similarity, - min_similarity=min_similarity, - always_include_seed_sequence=always_include_seed_sequence, - num_ensemble_prompts=num_ensemble_prompts, - random_seed=random_seed, - ) - - -class PromptFuture(MSAFuture, FutureBase): - """ - Represents a result of a prompt job. - - Attributes - ---------- - session : APISession - An instance of APISession for API interactions. - job : Job - The PoET scoring job. - page_size : int - The number of results to fetch in a single page. - - Methods - ------- - get(verbose=False) - Get the final results of the PoET scoring job. - - Returns - ------- - List[PoetScoreResult] - The list of results from the PoET scoring job. - """ - - job_type = "/align/prompt" - - def __init__( - self, - session: APISession, - job: Job, - page_size=config.POET_PAGE_SIZE, - msa_id: Optional[str] = None, - ): - """ - init a PoetScoreFuture instance. - - Parameters - ---------- - session (APISession): An instance of APISession for API interactions. - job (Job): The PoET scoring job. - page_size (int, optional): The number of results to fetch in a single page. Defaults to config.POET_PAGE_SIZE. - - """ - super().__init__(session, job) - self.page_size = page_size - - if msa_id is None: - msa_id = job_args_get(self.session, job.job_id).get("root_msa") - self._msa_id = msa_id - - def get(self, verbose: bool = False) -> csv.reader: - return self.get_prompt() - - -Prompt = Union[PromptFuture, str] - - -def validate_prompt(prompt: Prompt): - """helper function to validate prompt_id is prompt type""" - if not (isinstance(prompt, PromptFuture) or isinstance(prompt, str)): - raise ValueError( - f"Expect prompt to be either a PromptFuture or str, got {type(prompt)}" - ) - if isinstance(prompt, str): - return prompt - return prompt.prompt_id - - -def validate_msa(msa: Union[MSAFuture, str]): - """helper function to validate prompt_id is prompt type""" - if not (isinstance(msa, MSAFuture) or isinstance(msa, str)): - raise ValueError( - f"Expect prompt to be either a MSAFuture or str, got {type(msa)}" - ) - if isinstance(msa, str): - return msa - return msa.msa_id - - -class AlignAPI: - """API interface for calling Poet and Align endpoints""" - - def __init__(self, session: APISession): - self.session = session - - def upload_msa(self, msa_file) -> MSAFuture: - """ - Upload an MSA from file. - - Parameters - ---------- - msa_file : str, optional - Ready-made MSA. If not provided, default value is None. - - Raises - ------ - APIError - If there is an issue with the API request. - - Returns - ------- - MSAJob - Job object containing the details of the MSA upload. - """ - return msa_post(self.session, msa_file=msa_file) - - def create_msa(self, seed: bytes) -> MSAFuture: - """ - Construct an MSA via homology search with the seed sequence. - - Parameters - ---------- - seed : bytes - Seed sequence for the MSA construction. - - Raises - ------ - APIError - If there is an issue with the API request. - - Returns - ------- - MSAJob - Job object containing the details of the MSA construction. - """ - return msa_post(self.session, seed=seed) - - def upload_prompt(self, prompt_file: BinaryIO) -> Job: - """ - Directly upload a prompt. - - Bypass post_msa and prompt_post steps entirely. In this case PoET will use the prompt as is. - You can specify multiple prompts (one per replicate) with an and newline between CSVs. - - Parameters - ---------- - prompt_file : BinaryIO - Binary I/O object representing the prompt file. - - Raises - ------ - APIError - If there is an issue with the API request. - - Returns - ------- - PromptJob - An object representing the status and results of the prompt job. - """ - return upload_prompt_post(self.session, prompt_file) - - def get_prompt(self, job: Job, prompt_index: Optional[int] = None) -> csv.reader: - """ - Get prompts for a given job. - - Parameters - ---------- - job : Job - The job for which to retrieve data. - prompt_index : Optional[int] - The replicate number for the prompt (input_type=-PROMPT only) - - Returns - ------- - csv.reader - A CSV reader for the response data. - """ - return get_input( - self.session, job, PoetInputType.PROMPT, prompt_index=prompt_index - ) - - def get_seed(self, job: Job) -> csv.reader: - """ - Get input data for a given msa job. - - Parameters - ---------- - job : Job - The job for which to retrieve data. - - Returns - ------- - csv.reader - A CSV reader for the response data. - """ - return get_input(self.session, job, PoetInputType.INPUT) - - def get_msa(self, job: Job) -> csv.reader: - """ - Get generated MSA for a given job. - - Parameters - ---------- - job : Job - The job for which to retrieve data. - - Returns - ------- - csv.reader - A CSV reader for the response data. - """ - return get_input(self.session, job, PoetInputType.MSA) + return Job.model_validate(response.json()) diff --git a/openprotein/api/assaydata.py b/openprotein/api/assaydata.py new file mode 100644 index 0000000..c72a001 --- /dev/null +++ b/openprotein/api/assaydata.py @@ -0,0 +1,216 @@ +from openprotein.base import APISession +from openprotein.errors import APIError +from openprotein.schemas import AssayDataPage, AssayMetadata +from pydantic import TypeAdapter + + +def list_models(session: APISession, assay_id: str) -> list: + """ + List models assoicated with assay. + + Parameters + ---------- + session : APISession + Session object for API communication. + assay_id : str + assay ID + + Returns + ------- + List + List of models + """ + endpoint = "v1/models" + response = session.get(endpoint, params={"assay_id": assay_id}) + return response.json() + + +def assaydata_post( + session: APISession, + assay_file, + assay_name: str, + assay_description: str | None = "", +) -> AssayMetadata: + """ + Post assay data. + + Parameters + ---------- + session : APISession + Session object for API communication. + assay_file : str + Path to the assay data file. + assay_name : str + Name of the assay. + assay_description : str, optional + Description of the assay, by default ''. + + Returns + ------- + AssayMetadata + Metadata of the posted assay data. + """ + endpoint = "v1/assaydata" + + files = {"assay_data": assay_file} + data = {"assay_name": assay_name, "assay_description": assay_description} + + response = session.post(endpoint, files=files, data=data) + if response.status_code == 200: + return TypeAdapter(AssayMetadata).validate_python(response.json()) + else: + raise APIError(f"Unable to post assay data: {response.text}") + + +def assaydata_list(session: APISession) -> list[AssayMetadata]: + """ + Get a list of all assay metadata. + + Parameters + ---------- + session : APISession + Session object for API communication. + + Returns + ------- + List[AssayMetadata] + List of all assay metadata. + + Raises + ------ + APIError + If an error occurs during the API request. + """ + endpoint = "v1/assaydata" + response = session.get(endpoint) + if response.status_code == 200: + return TypeAdapter(list[AssayMetadata]).validate_python(response.json()) + else: + raise APIError(f"Unable to list assay data: {response.text}") + + +def get_assay_metadata(session: APISession, assay_id: str) -> AssayMetadata: + """ + Retrieve metadata for a specified assay. + + + Parameters + ---------- + session : APISession + The current API session for communication with the server. + assay_id : str + The identifier of the assay for which metadata is to be retrieved. + + Returns + ------- + AssayMetadata + An AssayMetadata that contains the metadata for the specified assay. + + Raises + ------ + InvalidJob + If no assay metadata with the specified assay_id is found. + """ + + endpoint = "v1/assaydata/metadata" + response = session.get(endpoint, params={"assay_id": assay_id}) + if response.status_code == 200: + data = TypeAdapter(AssayMetadata).validate_python(response.json()) + else: + raise APIError(f"Unable to list assay data: {response.text}") + if data == []: + raise APIError(f"No assay with id={assay_id} found") + return data + + +def assaydata_put( + session: APISession, + assay_id: str, + assay_name: str | None = None, + assay_description: str | None = None, +) -> AssayMetadata: + """ + Update assay metadata. + + Parameters + ---------- + session : APISession + Session object for API communication. + assay_id : str + Id of the assay. + assay_name : str, optional + New name of the assay, by default None. + assay_description : str, optional + New description of the assay, by default None. + + Returns + ------- + AssayMetadata + Updated metadata of the assay. + + Raises + ------ + APIError + If an error occurs during the API request. + """ + endpoint = f"v1/assaydata/{assay_id}" + data = {} + if assay_name is not None: + data["assay_name"] = assay_name + if assay_description is not None: + data["assay_description"] = assay_description + + response = session.put(endpoint, data=data) + if response.status_code == 200: + return TypeAdapter(AssayMetadata).validate_python(response.json()) + else: + raise APIError(f"Unable to update assay data: {response.text}") + + +def assaydata_page_get( + session: APISession, + assay_id: str, + measurement_name: str | None = None, + page_offset: int = 0, + page_size: int = 1000, + data_format: str = "wide", +) -> AssayDataPage: + """ + Get a page of assay data. + + Parameters + ---------- + session : APISession + Session object for API communication. + assay_id : str + Id of the assay. + measurement_name : str, optional + Name of the measurement, by default None. + page_offset : int, optional + Offset of the page, by default 0. + page_size : int, optional + Size of the page, by default 1000. + data_format : str, optional + data_format of the data, by default 'wide'. + + Returns + ------- + AssayDataPage + Page of assay data. + + Raises + ------ + APIError + If an error occurs during the API request. + """ + endpoint = f"v1/assaydata/{assay_id}" + + params = {"page_offset": page_offset, "page_size": page_size, "format": data_format} + if measurement_name is not None: + params["measurement_name"] = measurement_name + + response = session.get(endpoint, params=params) + if response.status_code == 200: + return TypeAdapter(AssayDataPage).validate_python(response.json()) + else: + raise APIError(f"Unable to get assay data page: {response.text}") diff --git a/openprotein/api/data.py b/openprotein/api/data.py deleted file mode 100644 index 0ab9b45..0000000 --- a/openprotein/api/data.py +++ /dev/null @@ -1,532 +0,0 @@ -import pandas as pd -import openprotein.pydantic as pydantic -from openprotein.pydantic import BaseModel -from typing import Optional, List, Union -from datetime import datetime -from io import BytesIO -from openprotein.errors import APIError -from openprotein.base import APISession -import openprotein.config as config - - -class AssayMetadata(BaseModel): - assay_name: str - assay_description: str - assay_id: str - original_filename: str - created_date: datetime - num_rows: int - num_entries: int - measurement_names: List[str] - sequence_length: Optional[int] = None - - -class AssayDataRow(BaseModel): - mut_sequence: str - measurement_values: List[Union[float, None]] - - -class AssayDataPage(BaseModel): - assaymetadata: AssayMetadata - page_size: int - page_offset: int - assaydata: List[AssayDataRow] - - -def list_models(session: APISession, assay_id: str) -> List: - """ - List models assoicated with assay. - - Parameters - ---------- - session : APISession - Session object for API communication. - assay_id : str - assay ID - - Returns - ------- - List - List of models - """ - endpoint = "v1/models" - response = session.get(endpoint, params={"assay_id": assay_id}) - return response.json() - - -def assaydata_post( - session: APISession, - assay_file, - assay_name: str, - assay_description: Optional[str] = "", -) -> AssayMetadata: - """ - Post assay data. - - Parameters - ---------- - session : APISession - Session object for API communication. - assay_file : str - Path to the assay data file. - assay_name : str - Name of the assay. - assay_description : str, optional - Description of the assay, by default ''. - - Returns - ------- - AssayMetadata - Metadata of the posted assay data. - """ - endpoint = "v1/assaydata" - - files = {"assay_data": assay_file} - data = {"assay_name": assay_name, "assay_description": assay_description} - - response = session.post(endpoint, files=files, data=data) - if response.status_code == 200: - return pydantic.parse_obj_as(AssayMetadata, response.json()) - else: - raise APIError(f"Unable to post assay data: {response.text}") - - -def assaydata_list(session: APISession) -> List[AssayMetadata]: - """ - Get a list of all assay metadata. - - Parameters - ---------- - session : APISession - Session object for API communication. - - Returns - ------- - List[AssayMetadata] - List of all assay metadata. - - Raises - ------ - APIError - If an error occurs during the API request. - """ - endpoint = "v1/assaydata" - response = session.get(endpoint) - if response.status_code == 200: - return pydantic.parse_obj_as(List[AssayMetadata], response.json()) - else: - raise APIError(f"Unable to list assay data: {response.text}") - - -def get_assay_metadata(session: APISession, assay_id: str) -> AssayMetadata: - """ - Retrieve metadata for a specified assay. - - - Parameters - ---------- - session : APISession - The current API session for communication with the server. - assay_id : str - The identifier of the assay for which metadata is to be retrieved. - - Returns - ------- - AssayMetadata - An AssayMetadata that contains the metadata for the specified assay. - - Raises - ------ - InvalidJob - If no assay metadata with the specified assay_id is found. - """ - - endpoint = "v1/assaydata/metadata" - response = session.get(endpoint, params={"assay_id": assay_id}) - if response.status_code == 200: - data = pydantic.parse_obj_as(AssayMetadata, response.json()) - else: - raise APIError(f"Unable to list assay data: {response.text}") - if data == []: - raise APIError(f"No assay with id={assay_id} found") - return data - - -def assaydata_put( - session: APISession, - assay_id: str, - assay_name: Optional[str] = None, - assay_description: Optional[str] = None, -) -> AssayMetadata: - """ - Update assay metadata. - - Parameters - ---------- - session : APISession - Session object for API communication. - assay_id : str - Id of the assay. - assay_name : str, optional - New name of the assay, by default None. - assay_description : str, optional - New description of the assay, by default None. - - Returns - ------- - AssayMetadata - Updated metadata of the assay. - - Raises - ------ - APIError - If an error occurs during the API request. - """ - endpoint = f"v1/assaydata/{assay_id}" - data = {} - if assay_name is not None: - data["assay_name"] = assay_name - if assay_description is not None: - data["assay_description"] = assay_description - - response = session.put(endpoint, data=data) - if response.status_code == 200: - return pydantic.parse_obj_as(AssayMetadata, response.json()) - else: - raise APIError(f"Unable to update assay data: {response.text}") - - -def assaydata_page_get( - session: APISession, - assay_id: str, - measurement_name: Optional[str] = None, - page_offset: int = 0, - page_size: int = 1000, - data_format: str = "wide", -) -> AssayDataPage: - """ - Get a page of assay data. - - Parameters - ---------- - session : APISession - Session object for API communication. - assay_id : str - Id of the assay. - measurement_name : str, optional - Name of the measurement, by default None. - page_offset : int, optional - Offset of the page, by default 0. - page_size : int, optional - Size of the page, by default 1000. - data_format : str, optional - data_format of the data, by default 'wide'. - - Returns - ------- - AssayDataPage - Page of assay data. - - Raises - ------ - APIError - If an error occurs during the API request. - """ - endpoint = f"v1/assaydata/{assay_id}" - - params = {"page_offset": page_offset, "page_size": page_size, "format": data_format} - if measurement_name is not None: - params["measurement_name"] = measurement_name - - response = session.get(endpoint, params=params) - if response.status_code == 200: - return pydantic.parse_obj_as(AssayDataPage, response.json()) - else: - raise APIError(f"Unable to get assay data page: {response.text}") - - -class AssayDataset: - """Future Job for manipulating results""" - - def __init__(self, session: APISession, metadata: AssayMetadata): - """ - init for AssayDataset. - - Parameters - ---------- - session : APISession - Session object for API communication. - metadata : AssayMetadata - Metadata object of the assay data. - """ - self.session = session - self.metadata = metadata - self.page_size = config.BASE_PAGE_SIZE - if self.page_size > 1000: - self.page_size = 1000 - - def __str__(self) -> str: - return str(self.metadata) - - def __repr__(self) -> str: - return repr(self.metadata) - - @property - def id(self): - return self.metadata.assay_id - - @property - def name(self): - return self.metadata.assay_name - - @property - def description(self): - return self.metadata.assay_description - - @property - def measurement_names(self): - return self.metadata.measurement_names - - @property - def sequence_length(self): - return self.metadata.sequence_length - - def __len__(self): - return self.metadata.num_rows - - @property - def shape(self): - return (len(self), len(self.measurement_names) + 1) - - def list_models(self): - """ - List models assoicated with assay. - - Returns - ------- - List - List of models - """ - return list_models(self.session, self.id) - - def update( - self, assay_name: Optional[str] = None, assay_description: Optional[str] = None - ) -> None: - """ - Update the assay metadata. - - Parameters - ---------- - assay_name : str, optional - New name of the assay, by default None. - assay_description : str, optional - New description of the assay, by default None. - - Returns - ------- - None - """ - metadata = assaydata_put( - self.session, - self.id, - assay_name=assay_name, - assay_description=assay_description, - ) - self.metadata = metadata - - def _get_all(self, verbose: bool = False) -> pd.DataFrame: - """ - Get all assay data. - - Returns - ------- - pd.DataFrame - Dataframe containing all assay data. - """ - step = self.page_size - - results = [] - num_returned = step - offset = 0 - - while num_returned >= step: - try: - result = self.get_slice(offset, offset + step) - results.append(result) - num_returned = len(result) - offset += num_returned - except APIError as exc: - if verbose: - print(f"Failed to get results: {exc}") - return pd.concat(results) - return pd.concat(results) - - def get_first(self) -> pd.DataFrame: - """ - Get head slice of assay data. - - Returns - ------- - pd.DataFrame - Dataframe containing the slice of assay data. - """ - rows = [] - entries = assaydata_page_get(self.session, self.id, page_offset=0, page_size=1) - for row in entries.assaydata: - row = [row.mut_sequence] + row.measurement_values - rows.append(row) - table = pd.DataFrame(rows, columns=["sequence"] + self.measurement_names) - return table - - def get_slice(self, start: int, end: int) -> pd.DataFrame: - """ - Get a slice of assay data. - - Parameters - ---------- - start : int - Start index of the slice. - end : int - End index of the slice. - - Returns - ------- - pd.DataFrame - Dataframe containing the slice of assay data. - """ - rows = [] - page_size = self.page_size - # loop over the range - for i in range(start, end, page_size): - # the last page might be smaller than the page size - current_page_size = min(page_size, end - i) - - entries = assaydata_page_get( - self.session, self.id, page_offset=i, page_size=current_page_size - ) - - for row in entries.assaydata: - row = [row.mut_sequence] + row.measurement_values - rows.append(row) - - table = pd.DataFrame(rows, columns=["sequence"] + self.measurement_names) - return table - - -class DataAPI: - """API interface for calling AssayData endpoints""" - - def __init__(self, session: APISession): - """ - init the DataAPI. - - Parameters - ---------- - session : APISession - Session object for API communication. - """ - self.session = session - - def list(self) -> List[AssayDataset]: - """ - List all assay datasets. - - Returns - ------- - List[AssayDataset] - List of all assay datasets. - """ - metadata = assaydata_list(self.session) - return [AssayDataset(self.session, x) for x in metadata] - - def create( - self, table: pd.DataFrame, name: str, description: Optional[str] = None - ) -> AssayDataset: - """ - Create a new assay dataset. - - Parameters - ---------- - table : pd.DataFrame - DataFrame containing the assay data. - name : str - Name of the assay dataset. - description : str, optional - Description of the assay dataset, by default None. - - Returns - ------- - AssayDataset - Created assay dataset. - """ - stream = BytesIO() - table.to_csv(stream, index=False) - stream.seek(0) - metadata = assaydata_post( - self.session, stream, name, assay_description=description - ) - metadata.sequence_length = len(table["sequence"].values[0]) - return AssayDataset(self.session, metadata) - - def get(self, assay_id: str, verbose: bool = False) -> AssayMetadata: - """ - Get an assay dataset by its ID. - - Parameters - ---------- - assay_id : str - ID of the assay dataset. - - Returns - ------- - AssayDataset - Assay dataset with the specified ID. - - Raises - ------ - KeyError - If no assay dataset with the given ID is found. - """ - return get_assay_metadata(self.session, assay_id) - - def load_assay(self, assay_id: str) -> AssayDataset: - """ - Reload a Submitted job to resume from where you left off! - - - Parameters - ---------- - assay_id : str - The identifier of the job whose details are to be loaded. - - Returns - ------- - Job - Job - - Raises - ------ - HTTPError - If the request to the server fails. - InvalidJob - If the Job is of the wrong type - - """ - metadata = self.get(assay_id) - # if job_details.job_type != JobType.train: - # raise InvalidJob(f"Job {job_id} is not of type {JobType.train}") - return AssayDataset( - self.session, - metadata, - ) - - def __len__(self) -> int: - """ - Get the number of assay datasets. - - Returns - ------- - int - Number of assay datasets. - """ - return len(self.list()) diff --git a/openprotein/api/deprecated/__init__.py b/openprotein/api/deprecated/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openprotein/api/deprecated/poet.py b/openprotein/api/deprecated/poet.py new file mode 100644 index 0000000..c792cb5 --- /dev/null +++ b/openprotein/api/deprecated/poet.py @@ -0,0 +1,304 @@ +import io +import random + +import requests +from openprotein import config +from openprotein.base import APISession +from openprotein.errors import APIError, InvalidParameterError, MissingParameterError +from openprotein.schemas import PoetGenerateJob, PoetScoreJob, PoetSSPJob + + +def poet_score_post( + session: APISession, prompt_id: str, queries: list[bytes] | list[str] +) -> PoetScoreJob: + """ + Submits a job to score sequences based on the given prompt. + + Parameters + ---------- + session : APISession + An instance of APISession to manage interactions with the API. + prompt_id : str + The ID of the prompt. + queries : List[str] + A list of query sequences to be scored. + + Raises + ------ + APIError + If there is an issue with the API request. + + Returns + ------- + PoetScoreJob + An object representing the status and results of the scoring job. + """ + endpoint = "v1/poet/score" + + if len(queries) == 0: + raise MissingParameterError("Must include queries for scoring!") + if not prompt_id: + raise MissingParameterError("Must include prompt_id in request!") + + queries = [i.encode() if isinstance(i, str) else i for i in queries] + try: + variant_file = io.BytesIO(b"\n".join(queries)) + params = {"prompt_id": prompt_id} + response = session.post( + endpoint, files={"variant_file": variant_file}, params=params + ) + return PoetScoreJob.model_validate(response.json()) + except Exception as exc: + raise APIError(f"Failed to post poet score: {exc}") from exc + + +def poet_score_get( + session: APISession, job_id, page_size=config.POET_PAGE_SIZE, page_offset=0 +): + """ + Fetch a page of results from a PoET score job. + + Parameters + ---------- + session : APISession + An instance of APISession to manage interactions with the API. + job_id : str + The ID of the PoET scoring job to fetch results from. + page_size : int, optional + The number of results to fetch in a single page. Defaults to config.POET_PAGE_SIZE. + page_offset : int, optional + The offset (number of results) to start fetching results from. Defaults to 0. + + Raises + ------ + APIError + If the provided page size is larger than the maximum allowed page size. + + Returns + ------- + PoetScoreJob + An object representing the PoET scoring job, including its current status and results (if any). + """ + endpoint = "v1/poet/score" + + if page_size > config.POET_MAX_PAGE_SIZE: + raise APIError( + f"Page size must be less than the max for PoET: {config.POET_MAX_PAGE_SIZE}" + ) + + response = session.get( + endpoint, + params={"job_id": job_id, "page_size": page_size, "page_offset": page_offset}, + ) + + # return results to be assembled together + return PoetScoreJob.model_validate(response.json()) + + +def poet_single_site_post( + session: APISession, + variant, + parent_id: str | None = None, + prompt_id: str | None = None, +) -> PoetSSPJob: + """ + Request PoET single-site analysis for a variant. + + This function will mutate every position in the variant to every amino acid and return the scores. + Note that if parent_id is set then it will inherit all prompt properties of that parent. + + Parameters + ---------- + session : APISession + An instance of APISession for API interactions. + variant : str + The variant to analyze. + parent_id : str, optional + The ID of the parent job. Either parent_id or prompt_id must be set. Defaults to None. + prompt_id : str, optional + The ID of the prompt. Either parent_id or prompt_id must be set. Defaults to None. + + Raises + ------ + APIError + If the input parameters are invalid or there is an issue with the API request. + + Returns + ------- + PoetSSPJob + An object representing the status and results of the PoET single-site analysis job. + Note that the input variant score is given as `X0X`. + """ + endpoint = "v1/poet/single_site" + + if (parent_id is None and prompt_id is None) or ( + parent_id is not None and prompt_id is not None + ): + raise InvalidParameterError("Either parent_id or prompt_id must be set.") + + if isinstance(variant, str): + variant = variant.encode() + + params = {"variant": variant} + if prompt_id is not None: + params["prompt_id"] = prompt_id + if parent_id is not None: + params["parent_id"] = parent_id + + try: + response = session.post(endpoint, params=params) + return PoetSSPJob.model_validate(response.json()) + except Exception as exc: + raise APIError(f"Failed to post poet single-site analysis: {exc}") from exc + + +def poet_single_site_get( + session: APISession, job_id: str, page_size: int = 100, page_offset: int = 0 +) -> PoetSSPJob: + """ + Fetch paged results of a PoET single-site analysis job. + + Parameters + ---------- + session : APISession + An instance of APISession for API interactions. + job_id : str + The ID of the PoET single-site analysis job to fetch results from. + page_size : int, optional + The number of results to fetch in a single page. Defaults to 100. + page_offset : int, optional + The offset (number of results) to start fetching results from. Defaults to 0. + + Raises + ------ + APIError + If there is an issue with the API request. + + Returns + ------- + PoetSSPJob + An object representing the status and results of the PoET single-site analysis job. + """ + endpoint = "v1/poet/single_site" + + params = {"job_id": job_id, "page_size": page_size, "page_offset": page_offset} + + try: + response = session.get(endpoint, params=params) + + except Exception as exc: + raise APIError( + f"Failed to get poet single-site analysis results: {exc}" + ) from exc + # return results to be assembled together + return PoetSSPJob.model_validate(response.json()) + + +def poet_generate_post( + session: APISession, + prompt_id: str, + num_samples: int = 100, + temperature: float = 1.0, + topk: float | None = None, + topp: float | None = None, + max_length: int | None = 1000, + random_seed: int | None = None, +) -> PoetGenerateJob: + """ + Generate protein sequences with a prompt. + + Parameters + ---------- + session : APISession + An instance of APISession for API interactions. + prompt_id : str + The ID of the prompt to generate samples from. + num_samples : int, optional + The number of samples to generate. Defaults to 100. + temperature : float, optional + The temperature for sampling. Higher values produce more random outputs. Defaults to 1.0. + topk : int, optional + The number of top-k residues to consider during sampling. Defaults to None. + topp : float, optional + The cumulative probability threshold for top-p sampling. Defaults to None. + max_length : int, optional + The maximum length of generated proteins. Defaults to 1000. + random_seed : int, optional + Seed for random number generation. Defaults to a random number. + + Raises + ------ + APIError + If there is an issue with the API request. + + Returns + ------- + Job + An object representing the status and information about the generation job. + """ + endpoint = "v1/poet/generate" + + if not (0.1 <= temperature <= 2): + raise InvalidParameterError("The 'temperature' must be between 0.1 and 2.") + if topk: + if not (2 <= topk <= 20): + raise InvalidParameterError("The 'topk' must be between 2 and 22.") + if topp: + if not (0 <= topp <= 1): + raise InvalidParameterError("The 'topp' must be between 0 and 1.") + if random_seed: + if not (0 <= random_seed <= 2**32): + raise InvalidParameterError("The 'random_seed' must be between 0 and 1.") + + if random_seed is None: + random_seed = random.randrange(2**32) + + params = { + "prompt_id": prompt_id, + "generate_n": num_samples, + "temperature": temperature, + "maxlen": max_length, + "seed": random_seed, + } + if topk is not None: + params["topk"] = topk + if topp is not None: + params["topp"] = topp + + try: + response = session.post(endpoint, params=params) + return PoetGenerateJob.model_validate(response.json()) + except Exception as exc: + raise APIError(f"Failed to post PoET generation request: {exc}") from exc + + +def poet_generate_get(session: APISession, job_id) -> requests.Response: + """ + Get the results of a PoET generation job. + + Parameters + ---------- + session : APISession + An instance of APISession for API interactions. + job_id : str + Job ID from a poet/generate job. + + Raises + ------ + APIError + If there is an issue with the API request. + + Returns + ------- + requests.Response + The response object containing the results of the PoET generation job. + """ + endpoint = "v1/poet/generate" + + params = {"job_id": job_id} + + try: + response = session.get(endpoint, params=params, stream=True) + return response + except Exception as exc: + raise APIError(f"Failed to get poet generation results: {exc}") from exc diff --git a/openprotein/api/design.py b/openprotein/api/design.py index 7951185..9d49a11 100644 --- a/openprotein/api/design.py +++ b/openprotein/api/design.py @@ -1,261 +1,5 @@ -from typing import Optional, Dict, List, Union, Literal, Any -from enum import Enum - from openprotein.base import APISession -from openprotein.api.jobs import AsyncJobFuture -from openprotein.schemas import JobType -import openprotein.config as config -from openprotein.jobs import JobType, Job - -from openprotein.errors import APIError -from openprotein.futures import FutureFactory, FutureBase -from openprotein.pydantic import BaseModel, Field, validator -from datetime import datetime -import re - - -class DesignMetadata(BaseModel): - y_mu: Optional[float] = None - y_var: Optional[float] = None - - -class DesignSubscore(BaseModel): - score: float - metadata: DesignMetadata - - -class DesignStep(BaseModel): - step: int - sample_index: int - sequence: str - # scores: List[int] - # subscores_metadata: List[List[DesignSubscore]] - __initial_scores: List[float] = Field( - ..., alias="scores" - ) # renaming 'scores' to 'initial_scores' # noqa: E501 - scores: List[List[DesignSubscore]] = Field( - ..., alias="subscores_metadata" - ) # renaming 'subscores_metadata' to 'scores' # noqa: E501 - # umap1: float - # umap2: float - - -class DesignResults(BaseModel): - status: str - job_id: str - job_type: str - created_date: datetime - start_date: datetime - end_date: Optional[datetime] - assay_id: str - num_rows: int - result: List[DesignStep] - - -class DirectionEnum(str, Enum): - gt = ">" - lt = "<" - eq = "=" - - -class Criterion(BaseModel): - target: float - weight: float - direction: str - - -class ModelCriterion(BaseModel): - criterion_type: Literal["model"] - model_id: str - measurement_name: str - criterion: Criterion - - -class NMutationCriterion(BaseModel): - criterion_type: Literal["n_mutations"] - # sequences: Optional[List[str]] - - -CriterionItem = Union[ModelCriterion, NMutationCriterion] - - -class DesignConstraint: - def __init__(self, sequence: str): - self.sequence = sequence - self.mutations = self.initialize(sequence) - - def initialize(self, sequence: str) -> Dict[int, List[str]]: - """Initialize with no changes allowed to the sequence.""" - return {i: [aa] for i, aa in enumerate(sequence, start=1)} - - def allow( - self, positions: Union[int, List[int]], amino_acids: Union[List[str], str] - ) -> None: - """Allow specific amino acids at given positions.""" - if isinstance(positions, int): - positions = [positions] - if isinstance(amino_acids, str): - amino_acids = list(amino_acids) - - for position in positions: - if position in self.mutations: - self.mutations[position].extend(amino_acids) - else: - self.mutations[position] = amino_acids - - def remove( - self, positions: Union[int, List[int]], amino_acids: Union[List[str], str] - ) -> None: - """Remove specific amino acids from being allowed at given positions.""" - if isinstance(positions, int): - positions = [positions] - if isinstance(amino_acids, str): - amino_acids = list(amino_acids) - - for position in positions: - if position in self.mutations: - for aa in amino_acids: - if aa in self.mutations[position]: - self.mutations[position].remove(aa) - - def as_dict(self) -> Dict[int, List[str]]: - """Convert the internal mutations representation into a dictionary.""" - return self.mutations - - -class DesignJobCreate(BaseModel): - assay_id: str - criteria: List[List[CriterionItem]] - num_steps: Optional[int] = 8 - pop_size: Optional[int] = None - n_offsprings: Optional[int] = None - crossover_prob: Optional[float] = None - crossover_prob_pointwise: Optional[float] = None - mutation_average_mutations_per_seq: Optional[int] = None - allowed_tokens: Optional[Union[DesignConstraint, Dict[int, List[str]]]] = None - - class Config: - arbitrary_types_allowed = True - - @validator("allowed_tokens", pre=True) - def ensure_dict(cls, v): - if isinstance(v, DesignConstraint): - return v.as_dict() - return v - - -def _validate_mutation_dict(d: dict, amino_acids: str = "ACDEFGHIKLMNPQRSTVWY"): - validated = {} - for k, v in d.items(): - _ = [i for i in v if i in amino_acids] - validated[k] = _ - return validated - - -def mutation_regex( - constraints: str, - amino_acids: Union[List[str], str] = "ACDEFGHIKLMNPQRSTVWY", - verbose: bool = False, -) -> dict: - """ - Parses a constraint string for sequence and return a mutation dict. - - Syntax supported: - * [AC] - position must be A or C ONLY - * X - position can be any amino acid - * A - position will always be A - * [^ACD] - anything except A, C or D - * X{3} - 3 consecutive positions of any residue - * A{3} - 3 consecutive positions of A - - Parameters - ---------- - constraints: A string representing the constraints on the protein sequence. - amino_acids: A list or string of all possible amino acids. - verbose: control verbosity - - Returns - ------- - dict : mutation dict - """ - if isinstance(amino_acids, str): - amino_acids = list(amino_acids) - constraints_dict = {} - - constraints_dict = {} - pos = 1 - - pattern = re.compile( - r"(\[[^\]]*\])|(\{[A-Z]+\})|([A-Z]\{\d+\})|([A-Z]\{\d+,\d*\})|(X\{\d+\})|([A-Z])|(X)" - ) - - for match in pattern.finditer(constraints): - token = match.group() - if verbose: - print(f"parsed: {token}") - - if token.startswith("[") and token.endswith("]"): - if "^" in token: - # Negation - excluded = set(token[2:-1]) - options = [aa for aa in amino_acids if aa not in excluded] - else: - # Specific options - options = list(token[1:-1]) - constraints_dict[pos] = options - pos += 1 - elif token.startswith("{") and token.endswith("}"): - # Ranges of positions or exact repetitions for specific amino acids - options = list(token[1:-1]) - constraints_dict[pos] = options - pos += 1 - elif "{" in token and "X" not in token: - # Ranges of positions or exact repetitions for specific amino acids - base, range_part = token.split("{") - if "," in range_part: - # Range specified, handle similarly to previous versions - start, end = map(int, range_part[:-1].split(",")) - for _ in range(start, end + 1): - constraints_dict[pos] = [base] - pos += 1 - else: - # Exact repetition specified - count = int(range_part[:-1]) - for _ in range(count): - constraints_dict[pos] = [base] - pos += 1 - elif token.startswith("X{") and token.endswith("}"): - # Fixed number of wildcard positions - num = int(token[2:-1]) - for _ in range(num): - constraints_dict[pos] = list(amino_acids) - pos += 1 - elif token == "X": - # Any amino acid - constraints_dict[pos] = list(amino_acids) - pos += 1 - else: - # Specific amino acid - constraints_dict[pos] = [token] - pos += 1 - - return _validate_mutation_dict(constraints_dict) - - -def position_mutation( - positions: List, amino_acids: Union[str, List] = "ACDEFGHIKLMNPQRSTVWY" -): - if isinstance(amino_acids, list): - amino_acids = "".join(amino_acids) - return {k: list(amino_acids) for k in positions} - - -def nochange(sequence: str): - return {k + 1: [v] for k, v in enumerate(sequence)} - - -def keep_cys(sequence: str): - return {k + 1: [v] for k, v in enumerate(sequence) if v == "C"} +from openprotein.schemas import DesignJobCreate, DesignResults, Job def create_design_job(session: APISession, design_job: DesignJobCreate): @@ -283,19 +27,18 @@ def create_design_job(session: APISession, design_job: DesignJobCreate): Job The created job as a Job instance. """ - params = design_job.dict(exclude_none=True) + params = design_job.model_dump(exclude_none=True) # print(f"sending design: {params}") response = session.post("v1/workflow/design/genetic-algorithm", json=params) - - return FutureFactory.create_future(session=session, response=response) + return Job.model_validate(response.json()) def get_design_results( session: APISession, job_id: str, - step: Optional[int] = None, - page_size: Optional[int] = None, - page_offset: Optional[int] = None, + step: int | None = None, + page_size: int | None = None, + page_offset: int | None = None, ) -> DesignResults: """ Retrieves the results of a Design job. @@ -336,173 +79,4 @@ def get_design_results( response = session.get(endpoint, params=params) - return DesignResults(**response.json()) - - -class DesignFutureMixin: - session: APISession - job: Job - - def get_results( - self, - step: Optional[int] = None, - page_size: Optional[int] = None, - page_offset: Optional[int] = None, - ) -> DesignResults: - """ - Retrieves the results of a Design job. - - This function retrieves the results of a Design job by making a GET request to design.. - - Parameters - ---------- - page_size : Optional[int], default is None - The number of results to be returned per page. If None, all results are returned. - page_offset : Optional[int], default is None - The number of results to skip. If None, defaults to 0. - - Returns - ------- - DesignJob - The job object representing the Design job. - - Raises - ------ - HTTPError - If the GET request does not succeed. - """ - return get_design_results( - self.session, - job_id=self.job.job_id, - step=step, - page_size=page_size, - page_offset=page_offset, - ) - - -class DesignFuture(DesignFutureMixin, AsyncJobFuture, FutureBase): - """Future Job for manipulating results""" - - job_type = [JobType.workflow_design] - - def __init__(self, session: APISession, job: Job, page_size=1000): - super().__init__(session, job) - self.page_size = page_size - - def __str__(self) -> str: - return str(self.job) - - def __repr__(self) -> str: - return repr(self.job) - - def _fmt_results(self, results) -> List[Dict]: - return [i.dict() for i in results] - - @property - def id(self): - return self.job.job_id - - def get(self, step: Optional[int] = None, verbose: bool = False) -> List[Dict]: - """ - Get all the results of the design job. - - Args: - verbose (bool, optional): If True, print verbose output. Defaults False. - - Raises: - APIError: If there is an issue with the API request. - - Returns: - List: A list of predict objects representing the results. - """ - page = self.page_size - - results = [] - num_returned = page - offset = 0 - - while num_returned >= page: - try: - response = self.get_results( - page_offset=offset, step=step, page_size=page - ) - results += response.result - num_returned = len(response.result) - offset += num_returned - except APIError as exc: - if verbose: - print(f"Failed to get results: {exc}") - return self._fmt_results(results) - - -class DesignAPI: - """interface for calling Design endpoints""" - - session: APISession - - def __init__(self, session: APISession): - self.session = session - - def create_design_job(self, design_job: DesignJobCreate) -> DesignFuture: - """ - Start a protein design job based on your assaydata, a trained ML model and Criteria (specified here). - - Parameters - ---------- - design_job : DesignJobCreate - The details of the design job to be created, with the following parameters: - - assay_id: The ID for the assay. - - criteria: A list of CriterionItem lists for evaluating the design. - - num_steps: The number of steps in the genetic algo. Default is 8. - - pop_size: The population size for the genetic algo. Default is None. - - n_offsprings: The number of offspring for the genetic algo. Default is None. - - crossover_prob: The crossover probability for the genetic algo. Default is None. - - crossover_prob_pointwise: The pointwise crossover probability for the genetic algo. Default is None. - - mutation_average_mutations_per_seq: The average number of mutations per sequence. Default is None. - - allowed_tokens: A dict of positions and allows tokens (e.g. *{1:['G','L']})* ) designating how mutations may occur. Default is None. - - Returns - ------- - DesignFuture - The created job as a DesignFuture instance. - """ - return create_design_job(self.session, design_job) - - def get_design_results( - self, - job_id: str, - step: Optional[int] = None, - page_size: Optional[int] = None, - page_offset: Optional[int] = None, - ) -> DesignResults: - """ - Retrieves the results of a Design job. - - Parameters - ---------- - job_id : str - The ID for the design job - step: int - The design step to retrieve, if None: retrieve all. - page_size : Optional[int], default is None - The number of results to be returned per page. If None, all results are returned. - page_offset : Optional[int], default is None - The number of results to skip. If None, defaults to 0. - - Returns - ------- - DesignJob - The job object representing the Design job. - - Raises - ------ - HTTPError - If the GET request does not succeed. - """ - return get_design_results( - self.session, - step=step, - job_id=job_id, - page_size=page_size, - page_offset=page_offset, - ) + return DesignResults.model_validate(response.json()) diff --git a/openprotein/api/embedding.py b/openprotein/api/embedding.py index dbe9bd2..c7a2ede 100644 --- a/openprotein/api/embedding.py +++ b/openprotein/api/embedding.py @@ -1,123 +1,26 @@ -from openprotein.base import APISession -from openprotein.api.jobs import AsyncJobFuture, MappedAsyncJobFuture, job_get -import openprotein.config as config -from openprotein.jobs import Job, ResultsParser, JobStatus -from openprotein.api.align import PromptFuture, validate_prompt -from openprotein.api.poet import ( - PoetGenerateFuture, - poet_score_post, - poet_single_site_post, - poet_generate_post, -) -from openprotein.futures import FutureBase, FutureFactory - -from openprotein.pydantic import BaseModel, parse_obj_as -import numpy as np -from typing import Optional, List, Union, Any import io -from datetime import datetime +import random +from typing import Iterator +import numpy as np +from openprotein.api.align import csv_stream +from openprotein.base import APISession +from openprotein.errors import InvalidParameterError +from openprotein.schemas import ( + AttnJob, + EmbeddingsJob, + GenerateJob, + LogitsJob, + ModelMetadata, + ScoreJob, + ScoreSingleSiteJob, +) +from pydantic import TypeAdapter PATH_PREFIX = "v1/embeddings" -class ModelDescription(BaseModel): - citation_title: Optional[str] = None - doi: Optional[str] = None - summary: str = "Protein language model for embeddings" - - -class TokenInfo(BaseModel): - id: int - token: str - primary: bool - description: str - - -class ModelMetadata(BaseModel): - model_id: str - description: ModelDescription - max_sequence_length: Optional[int] = None - dimension: int - output_types: List[str] - input_tokens: List[str] - output_tokens: Optional[List[str]] = None - token_descriptions: List[List[TokenInfo]] - - -class EmbeddedSequence(BaseModel): - class Config: - arbitrary_types_allowed = True - - sequence: bytes - embedding: np.ndarray - - def __iter__(self): - yield self.sequence - yield self.embedding - - def __len__(self): - return 2 - - def __getitem__(self, i): - if i == 0: - return self.sequence - elif i == 1: - return self.embedding - - -class SVDMetadata(BaseModel): - id: str - status: JobStatus - created_date: Optional[datetime] = None - model_id: str - n_components: int - reduction: Optional[str] = None - sequence_length: Optional[int] = None - - def is_done(self): - return self.status.done() - - -# split these out by module for another layer of control -class EmbBase: - # overridden by subclasses - # get correct emb model - model_id = None - - @classmethod - def get_model(cls): - if isinstance(cls.model_id, str): - return [cls.model_id] - return cls.model_id - - -class EmbFactory: - """Factory class for creating Future instances based on job_type.""" - - @staticmethod - def create_model(session, model_id, default=None): - """ - Create and return an instance of the appropriate Future class based on the job type. - - Returns: - - An instance of the appropriate Future class. - """ - # Dynamically discover all subclasses of FutureBase - future_classes = EmbBase.__subclasses__() - - # Find the Future class that matches the job type - for future_class in future_classes: - if model_id in future_class.get_model(): - return future_class(session=session, model_id=model_id) - # default to ProtembedModel - try: - return default(session=session, model_id=model_id) - except Exception: # type: ignore - raise ValueError(f"Unsupported model_id type: {model_id}") - - -def embedding_models_list_get(session: APISession) -> List[str]: +def list_models(session: APISession) -> list[str]: """ List available embeddings models. @@ -125,7 +28,7 @@ def embedding_models_list_get(session: APISession) -> List[str]: session (APISession): API session Returns: - List[str]: list of model names. + list[str]: list of model names. """ endpoint = PATH_PREFIX + "/models" @@ -134,35 +37,14 @@ def embedding_models_list_get(session: APISession) -> List[str]: return result -def embedding_model_get(session: APISession, model_id: str) -> ModelMetadata: +def get_model(session: APISession, model_id: str) -> ModelMetadata: endpoint = PATH_PREFIX + f"/models/{model_id}" response = session.get(endpoint) result = response.json() return ModelMetadata(**result) -def embedding_get(session: APISession, job_id: str) -> FutureBase: - """ - Get Job associated with the given request ID. - - Parameters - ---------- - session : APISession - Session object for API communication. - job_id : str - job ID to fetch - - Returns - ------- - sequences : List[bytes] - """ - - # endpoint = PATH_PREFIX + f"/{job_id}" - # response = session.get(endpoint) - return FutureFactory.create_future(session=session, job_id=job_id) - - -def embedding_get_sequences(session: APISession, job_id: str) -> List[bytes]: +def get_request_sequences(session: APISession, job_id: str) -> list[bytes]: """ Get results associated with the given request ID. @@ -179,11 +61,11 @@ def embedding_get_sequences(session: APISession, job_id: str) -> List[bytes]: """ endpoint = PATH_PREFIX + f"/{job_id}/sequences" response = session.get(endpoint) - return parse_obj_as(List[bytes], response.json()) + return TypeAdapter(list[bytes]).validate_python(response.json()) -def embedding_get_sequence_result( - session: APISession, job_id: str, sequence: bytes +def request_get_sequence_result( + session: APISession, job_id: str, sequence: str | bytes ) -> bytes: """ Get encoded result for a sequence from the request ID. @@ -208,7 +90,7 @@ def embedding_get_sequence_result( return response.content -def decode_embedding(data: bytes) -> np.ndarray: +def result_decode(data: bytes) -> np.ndarray: """ Decode embedding. @@ -222,66 +104,55 @@ def decode_embedding(data: bytes) -> np.ndarray: return np.load(s, allow_pickle=False) -class EmbeddingResultFuture(MappedAsyncJobFuture, FutureBase): - """Future Job for manipulating results""" - - job_type = [ - "/embeddings/embed", - "/embeddings/svd", - "/embeddings/attn", - "/embeddings/logits", - "/embeddings/embed_reduced", - "/svd/fit", - "/svd/embed", - ] - - def __init__( - self, - session: APISession, - job: Job, - sequences=None, - max_workers=config.MAX_CONCURRENT_WORKERS, - ): - super().__init__(session, job, max_workers) - self._sequences = sequences - - def get(self, verbose=False) -> List: - return super().get(verbose=verbose) +def request_get_score_result(session: APISession, job_id: str) -> Iterator[list[str]]: + """ + Get encoded result for a sequence from the request ID. - @property - def sequences(self): - if self._sequences is None: - self._sequences = embedding_get_sequences(self.session, self.job.job_id) - return self._sequences + Parameters + ---------- + session : APISession + Session object for API communication. + job_id : str + job ID to retrieve results from - @property - def id(self): - return self.job.job_id + Returns + ------- + csv.reader + """ + endpoint = PATH_PREFIX + f"/{job_id}/scores" + response = session.get(endpoint, stream=True) + return csv_stream(response) - def keys(self): - return self.sequences - def get_item(self, sequence: bytes) -> np.ndarray: - """ - Get embedding results for specified sequence. +def request_get_generate_result( + session: APISession, job_id: str +) -> Iterator[list[str]]: + """ + Get encoded result for a sequence from the request ID. - Args: - sequence (bytes): sequence to fetch results for + Parameters + ---------- + session : APISession + Session object for API communication. + job_id : str + job ID to retrieve results from - Returns: - np.ndarray: embeddings - """ - data = embedding_get_sequence_result(self.session, self.job.job_id, sequence) - return decode_embedding(data) + Returns + ------- + csv.reader + """ + endpoint = PATH_PREFIX + f"/{job_id}/generate" + response = session.get(endpoint, stream=True) + return csv_stream(response) -def embedding_model_post( +def request_post( session: APISession, model_id: str, - sequences: List[bytes], - reduction: Optional[str] = "MEAN", - prompt_id: Optional[str] = None, -): + sequences: list[bytes] | list[str], + reduction: str | None = "MEAN", + prompt_id: str | None = None, +) -> EmbeddingsJob: """ POST a request for embeddings from the given model ID. Returns a Job object referring to this request that can be used to retrieve results later. @@ -294,7 +165,7 @@ def embedding_model_post( model ID to request results from sequences : List[bytes] sequences to request results for - reduction : Optional[str] + reduction : str | None reduction to apply to the embeddings. options are None, "MEAN", or "SUM". defaul: "MEAN" kwargs: accepts prompt_id for Poet Jobs @@ -306,24 +177,23 @@ def embedding_model_post( endpoint = PATH_PREFIX + f"/models/{model_id}/embed" sequences_unicode = [(s if isinstance(s, str) else s.decode()) for s in sequences] - body = { + body: dict = { "sequences": sequences_unicode, } - if "prompt_id": + if prompt_id is not None: body["prompt_id"] = prompt_id - body["reduction"] = reduction + if reduction is not None: + body["reduction"] = reduction response = session.post(endpoint, json=body) - return FutureFactory.create_future( - session=session, response=response, sequences=sequences - ) + return EmbeddingsJob.model_validate(response.json()) -def embedding_model_logits_post( +def request_logits_post( session: APISession, model_id: str, - sequences: List[bytes], - prompt_id: Optional[str] = None, -) -> Job: + sequences: list[bytes] | list[str], + prompt_id: str | None = None, +) -> LogitsJob: """ POST a request for logits from the given model ID. Returns a Job object referring to this request that can be used to retrieve results later. @@ -344,23 +214,21 @@ def embedding_model_logits_post( endpoint = PATH_PREFIX + f"/models/{model_id}/logits" sequences_unicode = [(s if isinstance(s, str) else s.decode()) for s in sequences] - body = { + body: dict = { "sequences": sequences_unicode, } - if "prompt_id": + if prompt_id is not None: body["prompt_id"] = prompt_id response = session.post(endpoint, json=body) - return FutureFactory.create_future( - session=session, response=response, sequences=sequences - ) + return LogitsJob.model_validate(response.json()) -def embedding_model_attn_post( +def request_attn_post( session: APISession, model_id: str, - sequences: List[bytes], - prompt_id: Optional[str] = None, -) -> Job: + sequences: list[bytes] | list[str], + prompt_id: str | None = None, +) -> AttnJob: """ POST a request for attention embeddings from the given model ID. \ Returns a Job object referring to this request \ @@ -382,843 +250,143 @@ def embedding_model_attn_post( endpoint = PATH_PREFIX + f"/models/{model_id}/attn" sequences_unicode = [(s if isinstance(s, str) else s.decode()) for s in sequences] - body = { + body: dict = { "sequences": sequences_unicode, } - if "prompt_id": + if prompt_id is not None: body["prompt_id"] = prompt_id response = session.post(endpoint, json=body) - return FutureFactory.create_future( - session=session, response=response, sequences=sequences - ) - - -def svd_list_get(session: APISession) -> List[SVDMetadata]: - """Get SVD job metadata for all SVDs. Including SVD dimension and sequence lengths.""" - endpoint = PATH_PREFIX + "/svd" - response = session.get(endpoint) - return parse_obj_as(List[SVDMetadata], response.json()) + return AttnJob.model_validate(response.json()) -def svd_get(session: APISession, svd_id: str) -> SVDMetadata: - """Get SVD job metadata. Including SVD dimension and sequence lengths.""" - endpoint = PATH_PREFIX + f"/svd/{svd_id}" - response = session.get(endpoint) - return SVDMetadata(**response.json()) - - -def svd_delete(session: APISession, svd_id: str): +def request_score_post( + session: APISession, + model_id: str, + sequences: list[bytes] | list[str], + prompt_id: str | None = None, +) -> ScoreJob: """ - Delete and SVD model. + POST a request for sequence scoring for the given model ID. \ + Returns a Job object referring to this request \ + that can be used to retrieve results later. Parameters ---------- session : APISession Session object for API communication. - svd_id : str - SVD model to delete + model_id : str + model ID to request results from + sequences : List[bytes] + sequences to request results for Returns ------- - bool + job : Job """ + endpoint = PATH_PREFIX + f"/models/{model_id}/score" - endpoint = PATH_PREFIX + f"/svd/{svd_id}" - session.delete(endpoint) - return True + sequences_unicode = [(s if isinstance(s, str) else s.decode()) for s in sequences] + body: dict = { + "sequences": sequences_unicode, + } + if prompt_id is not None: + body["prompt_id"] = prompt_id + response = session.post(endpoint, json=body) + return ScoreJob.model_validate(response.json()) -def svd_fit_post( +def request_score_single_site_post( session: APISession, model_id: str, - sequences: List[bytes], - n_components: int = 1024, - reduction: Optional[str] = None, - prompt_id: Optional[str] = None, -): + base_sequence: bytes | str, + prompt_id: str | None = None, +) -> ScoreSingleSiteJob: """ - Create SVD fit job. + POST a request for single site mutation scoring for the given model ID. \ + Returns a Job object referring to this request \ + that can be used to retrieve results later. Parameters ---------- session : APISession Session object for API communication. model_id : str - model to use + model ID to request results from sequences : List[bytes] - sequences to SVD - n_components : int - number of SVD components to fit. default = 1024 - reduction : Optional[str] - embedding reduction to use for fitting the SVD. default = None + sequences to request results for Returns ------- - SVDJob + job : Job """ + endpoint = PATH_PREFIX + f"/models/{model_id}/score_single_site" - endpoint = PATH_PREFIX + "/svd" - - sequences_unicode = [(s if isinstance(s, str) else s.decode()) for s in sequences] - body = { - "model_id": model_id, - "sequences": sequences_unicode, - "n_components": n_components, + body: dict = { + "base_sequence": ( + base_sequence.decode() + if isinstance(base_sequence, bytes) + else base_sequence + ), } - if reduction is not None: - body["reduction"] = reduction if prompt_id is not None: body["prompt_id"] = prompt_id - response = session.post(endpoint, json=body) - # return job for metadata - return ResultsParser.parse_obj(response) + return ScoreSingleSiteJob.model_validate(response.json()) -def svd_embed_post(session: APISession, svd_id: str, sequences: List[bytes]) -> Job: - """ - POST a request for embeddings from the given SVD model. +def request_generate_post( + session: APISession, + model_id: str, + num_samples: int = 100, + temperature: float = 1.0, + topk: float | None = None, + topp: float | None = None, + max_length: int = 1000, + random_seed: int | None = None, + prompt_id: str | None = None, +) -> GenerateJob: + """ + POST a request for sequence generation for the given model ID. \ + Returns a Job object referring to this request \ + that can be used to retrieve results later. Parameters ---------- session : APISession Session object for API communication. - svd_id : str - SVD model to use - sequences : List[bytes] - sequences to SVD + model_id : str + model ID to request results from Returns ------- - Job - """ - endpoint = PATH_PREFIX + f"/svd/{svd_id}/embed" - - sequences_unicode = [(s if isinstance(s, str) else s.decode()) for s in sequences] - body = { - "sequences": sequences_unicode, - } - response = session.post(endpoint, json=body) - - return FutureFactory.create_future( - session=session, response=response, sequences=sequences - ) - - -class ProtembedModel(EmbBase): - """ - Class providing inference endpoints for protein embedding models served by OpenProtein. - """ - - model_id = "protembed" - - def __init__(self, session, model_id, metadata=None): - self.session = session - self.id = model_id - self._metadata = metadata - self.__doc__ = self.__fmt_doc() - - def __fmt_doc(self): - summary = str(self.metadata.description.summary) - return f"""\t{summary} - \t max_sequence_length = {self.metadata.max_sequence_length} - \t supported outputs = {self.metadata.output_types} - \t supported tokens = {self.metadata.input_tokens} - """ - - def __str__(self) -> str: - return self.id - - def __repr__(self) -> str: - return self.id - - @property - def metadata(self): - if self._metadata is None: - self._metadata = self.get_metadata() - return self._metadata - - def get_metadata(self) -> ModelMetadata: - """ - Get model metadata for this model. - - Returns - ------- - ModelMetadata - """ - if self._metadata is not None: - return self._metadata - self._metadata = embedding_model_get(self.session, self.id) - return self._metadata - - def embed( - self, sequences: List[bytes], reduction: Optional[str] = "MEAN" - ) -> EmbeddingResultFuture: - """ - Embed sequences using this model. - - Parameters - ---------- - sequences : List[bytes] - sequences to SVD - reduction: str - embeddings reduction to use (e.g. mean) - - Returns - ------- - EmbeddingResultFuture - """ - return embedding_model_post( - self.session, model_id=self.id, sequences=sequences, reduction=reduction - ) - - def logits(self, sequences: List[bytes]) -> EmbeddingResultFuture: - """ - logit embeddings for sequences using this model. - - Parameters - ---------- - sequences : List[bytes] - sequences to SVD - - Returns - ------- - EmbeddingResultFuture - """ - return embedding_model_logits_post(self.session, self.id, sequences) - - def attn(self, sequences: List[bytes]) -> EmbeddingResultFuture: - """ - Attention embeddings for sequences using this model. - - Parameters - ---------- - sequences : List[bytes] - sequences to SVD - - Returns - ------- - EmbeddingResultFuture - """ - return embedding_model_attn_post(self.session, self.id, sequences) - - def fit_svd( - self, - sequences: List[bytes], - n_components: int = 1024, - reduction: Optional[str] = None, - ) -> Any: - """ - Fit an SVD on the embedding results of this model. - - This function will create an SVDModel based on the embeddings from this model \ - as well as the hyperparameters specified in the args. - - Parameters - ---------- - sequences : List[bytes] - sequences to SVD - n_components: int - number of components in SVD. Will determine output shapes - reduction: str - embeddings reduction to use (e.g. mean) - - Returns - ------- - SVDModel - """ - model_id = self.id - job = svd_fit_post( - self.session, - model_id, - sequences, - n_components=n_components, - reduction=reduction, - ) - if isinstance(job, Job): - job_id = job.job_id - else: - job_id = job.job.job_id - metadata = svd_get(self.session, job_id) - return SVDModel(self.session, metadata) - - -class SVDModel(AsyncJobFuture, FutureBase): - """ - Class providing embedding endpoint for SVD models. \ - Also allows retrieving embeddings of sequences used to fit the SVD with `get`. - """ - - # actually a future, not a model! - job_type = "/svd" - - def __init__(self, session: APISession, metadata: SVDMetadata): - self.session = session - self._metadata = metadata - self._job = None - - def __str__(self) -> str: - return str(self.metadata) - - def __repr__(self) -> str: - return repr(self.metadata) - - @property - def metadata(self): - self._refresh_metadata() - return self._metadata - - def _refresh_metadata(self): - if not self._metadata.is_done(): - self._metadata = svd_get(self.session, self.id) - - @property - def id(self): - return self._metadata.id - - @property - def n_components(self): - return self._metadata.n_components - - @property - def sequence_length(self): - return self._metadata.sequence_length - - @property - def reduction(self): - return self._metadata.reduction - - def get_model(self) -> ProtembedModel: - """Fetch embeddings model""" - model = ProtembedModel(self.session, self._metadata.model_id) - return model - - @property - def model(self) -> ProtembedModel: - return self.get_model() - - def delete(self) -> bool: - """ - Delete this SVD model. - """ - return svd_delete(self.session, self.id) - - def get_job(self) -> Job: - """Get job associated with this SVD model""" - return job_get(self.session, self.id) - - def get(self, verbose: bool = False): - # overload for AsyncJobFuture - return self - - @property - def job(self) -> Job: - if self._job is None: - self._job = self.get_job() - return self._job - - @job.setter - def job(self, j): - self._job = j - - def get_inputs(self) -> List[bytes]: - """ - Get sequences used for embeddings job. - - Returns - ------- - List[bytes]: list of sequences - """ - return embedding_get_sequences(self.session, job_id=self.id) - - def get_embeddings(self) -> EmbeddingResultFuture: - """ - Get SVD embedding results for this model. - - Returns - ------- - EmbeddingResultFuture: class for futher job manipulation - """ - return EmbeddingResultFuture(self.session, self.get_job()) - - def embed(self, sequences: List[bytes]) -> EmbeddingResultFuture: - """ - Use this SVD model to reduce embeddings results. - - Parameters - ---------- - sequences : List[bytes] - List of protein sequences. - - Returns - ------- - EmbeddingResultFuture - Class for further job manipulation. - """ - return svd_embed_post(self.session, self.id, sequences) - # return EmbeddingResultFuture(self.session, job, sequences=sequences) - - -class OpenProteinModel(ProtembedModel): - """ - Class providing inference endpoints for proprietary protein embedding models served by OpenProtein. - - Examples - -------- - View specific model details (inc supported tokens) with the `?` operator. - - .. code-block:: python - - import openprotein - session = openprotein.connect(username="user", password="password") - session.embedding.prot_seq? - """ - - -class PoETModel(OpenProteinModel, EmbBase): - """ - Class for OpenProtein's foundation model PoET - NB. PoET functions are dependent on a prompt supplied via the align endpoints. - - Examples - -------- - View specific model details (inc supported tokens) with the `?` operator. - - .. code-block:: python - - import openprotein - session = openprotein.connect(username="user", password="password") - session.embedding.poet? - - - """ - - model_id = "poet" - - # Add model to explicitly require prompt_id - def __init__(self, session, model_id, metadata=None): - self.session = session - self.id = model_id - self._metadata = metadata - # could add prompt here? - - def embed( - self, - prompt: Union[str, PromptFuture], - sequences: List[bytes], - reduction: Optional[str] = "MEAN", - ) -> EmbeddingResultFuture: - """ - Embed sequences using this model. - - Parameters - ---------- - prompt: Union[str, PromptFuture] - prompt from an align workflow to condition Poet model - sequence : bytes - Sequence to embed. - reduction: str - embeddings reduction to use (e.g. mean) - Returns - ------- - EmbeddingResultFuture - """ - prompt_id = validate_prompt(prompt) - # return super().embed(sequences=sequences, reduction=reduction, prompt_id=prompt_id) - return embedding_model_post( - self.session, - model_id=self.id, - sequences=sequences, - prompt_id=prompt_id, - reduction=reduction, - ) - - def logits( - self, - prompt: Union[str, PromptFuture], - sequences: List[bytes], - ) -> EmbeddingResultFuture: - """ - logit embeddings for sequences using this model. - - Parameters - ---------- - prompt: Union[str, PromptFuture] - prompt from an align workflow to condition Poet model - sequence : bytes - Sequence to analyse. - - Returns - ------- - EmbeddingResultFuture - """ - prompt_id = validate_prompt(prompt) - # return super().logits(sequences=sequences, prompt_id=prompt_id) - return embedding_model_logits_post( - self.session, self.id, sequences=sequences, prompt_id=prompt_id - ) - - def attn(): - """Not Available for Poet.""" - raise ValueError("Attn not yet supported for poet") - - def score(self, prompt: Union[str, PromptFuture], sequences: List[bytes]): - """ - Score query sequences using the specified prompt. - - Parameters - ---------- - prompt: Union[str, PromptFuture] - prompt from an align workflow to condition Poet model - sequence : bytes - Sequence to analyse. - Returns - ------- - results - The scores of the query sequences. - """ - prompt_id = validate_prompt(prompt) - return poet_score_post(self.session, prompt_id, sequences) - - def single_site(self, prompt: Union[str, PromptFuture], sequence: bytes): - """ - Score all single substitutions of the query sequence using the specified prompt. - - Parameters - ---------- - prompt: Union[str, PromptFuture] - prompt from an align workflow to condition Poet model - sequence : bytes - Sequence to analyse. - Returns - ------- - results - The scores of the mutated sequence. - """ - prompt_id = validate_prompt(prompt) - return poet_single_site_post(self.session, sequence, prompt_id=prompt_id) - - def generate( - self, - prompt: Union[str, PromptFuture], - num_samples=100, - temperature=1.0, - topk=None, - topp=None, - max_length=1000, - seed=None, - ) -> PoetGenerateFuture: - """ - Generate protein sequences conditioned on a prompt. - - Parameters - ---------- - prompt: Union[str, PromptFuture] - prompt from an align workflow to condition Poet model - num_samples : int, optional - The number of samples to generate, by default 100. - temperature : float, optional - The temperature for sampling. Higher values produce more random outputs, by default 1.0. - topk : int, optional - The number of top-k residues to consider during sampling, by default None. - topp : float, optional - The cumulative probability threshold for top-p sampling, by default None. - max_length : int, optional - The maximum length of generated proteins, by default 1000. - seed : int, optional - Seed for random number generation, by default a random number. - - Raises - ------ - APIError - If there is an issue with the API request. - - Returns - ------- - Job - An object representing the status and information about the generation job. - """ - prompt_id = validate_prompt(prompt) - return poet_generate_post( - self.session, - prompt_id, - num_samples=num_samples, - temperature=temperature, - topk=topk, - topp=topp, - max_length=max_length, - random_seed=seed, - ) - - def fit_svd( - self, - prompt: Union[str, PromptFuture], - sequences: List[bytes], - n_components: int = 1024, - reduction: Optional[str] = None, - ) -> SVDModel: # type: ignore - """ - Fit an SVD on the embedding results of this model. - - This function will create an SVDModel based on the embeddings from this model \ - as well as the hyperparameters specified in the args. - - Parameters - ---------- - prompt: Union[str, PromptFuture] - prompt from an align workflow to condition Poet model - sequences : List[bytes] - sequences to SVD - n_components: int - number of components in SVD. Will determine output shapes - reduction: str - embeddings reduction to use (e.g. mean) - - - Returns - ------- - SVDModel - """ - prompt = validate_prompt(prompt) - - job = svd_fit_post( - self.session, - model_id=self.id, - sequences=sequences, - n_components=n_components, - reduction=reduction, - prompt_id=prompt, - ) - metadata = svd_get(self.session, job.job_id) - return SVDModel(self.session, metadata) - - -class ESMModel(ProtembedModel): - """ - Class providing inference endpoints for Facebook's ESM protein language Models. - - Examples - -------- - View specific model details (inc supported tokens) with the `?` operator. - - .. code-block:: python - - import openprotein - session = openprotein.connect(username="user", password="password") - session.embedding.esm2_t12_35M_UR50D?""" - - -class EmbeddingAPI: - """ - This class defines a high level interface for accessing the embeddings API. - - You can access all our models either via :meth:`get_model` or directly through the session's embedding attribute using the model's ID and the desired method. For example, to use the attention method on the protein sequence model, you would use ``session.embedding.prot_seq.attn()``. - - Examples - -------- - Accessing a model's method: - - .. code-block:: python - - # To call the attention method on the protein sequence model: - import openprotein - session = openprotein.connect(username="user", password="password") - session.embedding.prot_seq.attn() - - Using the `get_model` method: - - .. code-block:: python - - # Get a model instance by name: - import openprotein - session = openprotein.connect(username="user", password="password") - # list available models: - print(session.embedding.list_models() ) - # init model by name - model = session.embedding.get_model('prot-seq') + job : Job """ + endpoint = PATH_PREFIX + f"/models/{model_id}/generate" - # added for static typing, eg pylance, for autocomplete - # at init these are all overwritten. - prot_seq: OpenProteinModel - rotaprot_large_uniref50w: OpenProteinModel - rotaprot_large_uniref90_ft: OpenProteinModel - poet: PoETModel - - esm1b_t33_650M_UR50S: ESMModel - esm1v_t33_650M_UR90S_1: ESMModel - esm1v_t33_650M_UR90S_2: ESMModel - esm1v_t33_650M_UR90S_3: ESMModel - esm1v_t33_650M_UR90S_4: ESMModel - esm1v_t33_650M_UR90S_5: ESMModel - esm2_t12_35M_UR50D: ESMModel - esm2_t30_150M_UR50D: ESMModel - esm2_t33_650M_UR50D: ESMModel - esm2_t36_3B_UR50D: ESMModel - esm2_t6_8M_UR50D: ESMModel - - def __init__(self, session: APISession): - self.session = session - # dynamically add models from api list - self._load_models() - - def _load_models(self): - # Dynamically add model instances as attributes - precludes any drift - models = self.list_models() - for model in models: - model_name = model.id.replace("-", "_") # hyphens out - setattr(self, model_name, model) - - def list_models(self) -> List[ProtembedModel]: - """list models available for creating embeddings of your sequences""" - models = [] - for model_id in embedding_models_list_get(self.session): - models.append( - EmbFactory.create_model( - session=self.session, model_id=model_id, default=ProtembedModel - ) - ) - return models - - def get_model(self, name: str): - """ - Get model by model_id. - - ProtembedModel allows all the usual job manipulation: \ - e.g. making POST and GET requests for this model specifically. - - - Parameters - ---------- - model_id : str - the model identifier - - Returns - ------- - ProtembedModel - The model - - Raises - ------ - HTTPError - If the GET request does not succeed. - """ - model_name = name.replace("-", "_") - return getattr(self, model_name) - - def __get_results(self, job) -> EmbeddingResultFuture: - """ - Retrieves the results of an embedding job. - - Parameters - ---------- - job : Job - The embedding job whose results are to be retrieved. - - Returns - ------- - EmbeddingResultFuture - An instance of EmbeddingResultFuture - """ - return FutureFactory.create_future(job=job, session=self.session) - - def __fit_svd( - self, - model_id: str, - sequences: List[bytes], - n_components: int = 1024, - reduction: Optional[str] = None, - **kwargs, - ) -> SVDModel: - """ - Fit an SVD on the sequences with the specified model_id and hyperparameters (n_components). - - Parameters - ---------- - model_id : str - The ID of the model to fit the SVD on. - sequences : List[bytes] - The list of sequences to use for the SVD fitting. - n_components : int, optional - The number of components for the SVD, by default 1024. - reduction : Optional[str], optional - The reduction method to apply to the embeddings, by default None. + if not (0.1 <= temperature <= 2): + raise InvalidParameterError("The 'temperature' must be between 0.1 and 2.") + if topk is not None and not (2 <= topk <= 20): + raise InvalidParameterError("The 'topk' must be between 2 and 20.") + if topp is not None and not (0 <= topp <= 1): + raise InvalidParameterError("The 'topp' must be between 0 and 1.") + if random_seed is not None and not (0 <= random_seed <= 2**32): + raise InvalidParameterError("The 'random_seed' must be between 0 and 2^32.") - Returns - ------- - SVDModel - The model with the SVD fit. - """ - model = self.get_model(model_id) - return model.fit_svd( - sequences=sequences, - n_components=n_components, - reduction=reduction, - **kwargs, - ) + if random_seed is None: + random_seed = random.randrange(2**32) - def get_svd(self, svd_id: str) -> SVDModel: - """ - Get SVD job results. Including SVD dimension and sequence lengths. - - Requires a successful SVD job from fit_svd - - Parameters - ---------- - svd_id : str - The ID of the SVD job. - Returns - ------- - SVDModel - The model with the SVD fit. - """ - metadata = svd_get(self.session, svd_id) - return SVDModel(self.session, metadata) - - def __delete_svd(self, svd_id: str) -> bool: - """ - Delete SVD model. - - Parameters - ---------- - svd_id : str - The ID of the SVD job. - Returns - ------- - bool - True: successful deletion - - """ - return svd_delete(self.session, svd_id) - - def list_svd(self) -> List[SVDModel]: - """ - List SVD models made by user. - - Takes no args. - - Returns - ------- - list[SVDModel] - SVDModels - - """ - return [ - SVDModel(self.session, metadata) for metadata in svd_list_get(self.session) - ] - - def __get_svd_results(self, job: Job): - """ - Get SVD job results. Including SVD dimension and sequence lengths. - - Requires a successful SVD job from fit_svd - - Parameters - ---------- - job : Job - SVD JobFuture - Returns - ------- - SVDModel - The model with the SVD fit. - """ - return EmbeddingResultFuture(self.session, job) + body: dict = { + "n_sequences": num_samples, + "temperature": temperature, + "maxlen": max_length, + } + if topk is not None: + body["topk"] = topk + if topp is not None: + body["topp"] = topp + if random_seed is not None: + body["seed"] = random_seed + if prompt_id is not None: + body["prompt_id"] = prompt_id + response = session.post(endpoint, json=body) + return GenerateJob.model_validate(response.json()) diff --git a/openprotein/api/fold.py b/openprotein/api/fold.py index c8800b4..82ceb55 100644 --- a/openprotein/api/fold.py +++ b/openprotein/api/fold.py @@ -1,58 +1,12 @@ -from openprotein.base import APISession -from openprotein.api.jobs import Job, MappedAsyncJobFuture -import openprotein.config as config from openprotein.api.embedding import ModelMetadata -from openprotein.api.align import validate_msa, MSAFuture -import openprotein.pydantic as pydantic -from typing import Optional, List, Union, Tuple -from openprotein.futures import FutureBase, FutureFactory -from abc import ABC, abstractmethod - +from openprotein.base import APISession +from openprotein.schemas import FoldJob +from pydantic import TypeAdapter PATH_PREFIX = "v1/fold" -class FoldModelBase: - # overridden by subclasses - # get correct fold model - - model_id = None - - @classmethod - def get_model(cls): - if isinstance(cls.model_id, str): - return [cls.model_id] - return cls.model_id - - -class FoldModelFactory: - """Factory class for creating Future instances based on job_type.""" - - @staticmethod - def create_model(session, model_id, metadata=None, default=None): - """ - Create and return an instance of the appropriate Future class based on the job type. - - Returns: - - An instance of the appropriate Future class. - """ - # Dynamically discover all subclasses of FutureBase - future_classes = FoldModelBase.__subclasses__() - - # Find the Future class that matches the job type - for future_class in future_classes: - if model_id in future_class.get_model(): - return future_class( - session=session, model_id=model_id, metadata=metadata - ) - # default to FoldModel - try: - return default(session=session, model_id=model_id, metadata=metadata) - except Exception: - raise ValueError(f"Unsupported model_id type: {model_id}") - - -def fold_models_list_get(session: APISession) -> List[str]: +def fold_models_list_get(session: APISession) -> list[str]: """ List available fold models. @@ -76,7 +30,7 @@ def fold_model_get(session: APISession, model_id: str) -> ModelMetadata: return ModelMetadata(**result) -def fold_get_sequences(session: APISession, job_id: str) -> List[bytes]: +def fold_get_sequences(session: APISession, job_id: str) -> list[bytes]: """ Get results associated with the given request ID. @@ -93,11 +47,11 @@ def fold_get_sequences(session: APISession, job_id: str) -> List[bytes]: """ endpoint = PATH_PREFIX + f"/{job_id}/sequences" response = session.get(endpoint) - return pydantic.parse_obj_as(List[bytes], response.json()) + return TypeAdapter(list[bytes]).validate_python(response.json()) def fold_get_sequence_result( - session: APISession, job_id: str, sequence: bytes + session: APISession, job_id: str, sequence: bytes | str ) -> bytes: """ Get encoded result for a sequence from the request ID. @@ -122,57 +76,11 @@ def fold_get_sequence_result( return response.content -class FoldResultFuture(MappedAsyncJobFuture, FutureBase): - job_type = ["/embeddings/fold"] - """Future Job for manipulating results""" - - def __init__( - self, - session: APISession, - job: Job, - sequences=None, - max_workers=config.MAX_CONCURRENT_WORKERS, - ): - super().__init__(session, job, max_workers) - if sequences is None: - sequences = fold_get_sequences(self.session, job_id=job.job_id) - self._sequences = sequences - - @property - def sequences(self): - if self._sequences is None: - self._sequences = fold_get_sequences(self.session, self.job.job_id) - return self._sequences - - @property - def id(self): - return self.job.job_id - - def keys(self): - return self.sequences - - def get(self, verbose=False) -> List[Tuple[str, str]]: - return super().get(verbose=verbose) - - def get_item(self, sequence: bytes) -> bytes: - """ - Get fold results for specified sequence. - - Args: - sequence (bytes): sequence to fetch results for - - Returns: - np.ndarray: fold - """ - data = fold_get_sequence_result(self.session, self.job.job_id, sequence) - return data # - - def fold_models_esmfold_post( session: APISession, - sequences: List[bytes], - num_recycles: Optional[int] = None, -): + sequences: list[bytes], + num_recycles: int | None = None, +) -> FoldJob: """ POST a request for structure prediction using ESMFold. Returns a Job object referring to this request that can be used to retrieve results later. @@ -193,25 +101,23 @@ def fold_models_esmfold_post( endpoint = PATH_PREFIX + "/models/esmfold" sequences_unicode = [(s if isinstance(s, str) else s.decode()) for s in sequences] - body = { + body: dict = { "sequences": sequences_unicode, } if num_recycles is not None: body["num_recycles"] = num_recycles response = session.post(endpoint, json=body) - return FutureFactory.create_future( - session=session, response=response, sequences=sequences - ) + return FoldJob.model_validate(response.json()) def fold_models_alphafold2_post( session: APISession, - msa: Union[str, MSAFuture], - num_recycles: Optional[int] = None, - num_models: Optional[int] = 1, - num_relax: Optional[int] = 0, -): + msa_id: str, + num_recycles: int | None = None, + num_models: int = 1, + num_relax: int = 0, +) -> FoldJob: """ POST a request for structure prediction using AlphaFold2. Returns a Job object referring to this request that can be used to retrieve results later. @@ -220,14 +126,14 @@ def fold_models_alphafold2_post( ---------- session : APISession Session object for API communication. - msa : Union[str, MSAfuture] - MSA to use for structure prediction. The first sequence in the MSA is the query sequence. - num_recycles : Optional[int] = None - number of recycles for structure prediction - num_models : Optional[int] = 1 - number of models to predict - num_relax : Optional[int] = 0 - number of relaxation iterations to run + msa_id : str + ID of MSA to use for structure prediction. The first sequence in the MSA is the query sequence. + num_recycles : Optional, int. + Number of recycles for structure prediction. Default to nil which lets the system decide. + num_models : int + Number of models to predict. Defaults to 1. + num_relax : int + Number of relaxation iterations to run. Defaults to 0. Returns ------- @@ -235,10 +141,6 @@ def fold_models_alphafold2_post( """ endpoint = PATH_PREFIX + "/models/alphafold2" - msa_id = msa - if isinstance(msa, MSAFuture): - msa_id = msa.msa_id - body = { "msa_id": msa_id, "num_models": num_models, @@ -250,199 +152,4 @@ def fold_models_alphafold2_post( response = session.post(endpoint, json=body) # GET endpoint for AF2 expects the query sequence (first sequence) within the MSA # since we don't know what the is, leave the sequence out of the future to be retrieved when calling get() - return FutureFactory.create_future(session=session, response=response) - - -class FoldModel(ABC): - """ - ABC Class providing inference endpoints for protein fold models served by OpenProtein. - - Must implement fold() method. - """ - - def __init__(self, session, model_id, metadata=None): - self.session = session - self.id = model_id - self._metadata = metadata - - def __str__(self) -> str: - return self.id - - def __repr__(self) -> str: - return self.id - - @property - def metadata(self): - return self.get_metadata() - - def get_metadata(self) -> ModelMetadata: - """ - Get model metadata for this model. - - Returns - ------- - ModelMetadata - """ - if self._metadata is not None: - return self._metadata - self._metadata = fold_model_get(self.session, self.id) - return self._metadata - - @abstractmethod - def fold(self, sequence: str, **kwargs): - pass - - -class ESMFoldModel(FoldModel, FoldModelBase): - model_id = "esmfold" - - def __init__(self, session, model_id, metadata=None): - super().__init__(session, model_id, metadata) - self.id = self.model_id - - def fold(self, sequences: List[bytes], num_recycles: int = 1) -> FoldResultFuture: - """ - Fold sequences using this model. - - Parameters - ---------- - sequences : List[bytes] - sequences to fold - num_recycles : int - number of times to recycle models - Returns - ------- - FoldResultFuture - """ - return fold_models_esmfold_post( - self.session, sequences, num_recycles=num_recycles - ) - - -class AlphaFold2Model(FoldModel, FoldModelBase): - model_id = "alphafold2" - - def __init__(self, session, model_id, metadata=None): - super().__init__(session, model_id, metadata) - self.id = self.model_id - - def fold( - self, - msa: Union[str, MSAFuture], - num_recycles: Optional[int] = None, - num_models: int = 1, - num_relax: Optional[int] = 0, - ): - """ - Post sequences to alphafold model. - - Parameters - ---------- - msa : Union[str, MSAFuture] - msa - num_recycles : int - number of times to recycle models - num_models : int - number of models to train - best model will be used - max_msa : Union[str, int] - maximum number of sequences in the msa to use. - relax_max_iterations : int - maximum number of iterations - - Returns - ------- - job : Job - """ - if msa and not isinstance(msa, str): - msa = validate_msa(msa) - - return fold_models_alphafold2_post( - self.session, - msa, - num_recycles=num_recycles, - num_models=num_models, - num_relax=num_relax, - ) - - -def validate_fold_id(fold): - if isinstance(fold, str): - return fold - return fold.id - - -class FoldAPI: - """ - This class defines a high level interface for accessing the fold API. - """ - - esmfold: ESMFoldModel - alphafold2: AlphaFold2Model - - def __init__(self, session: APISession): - self.session = session - self._load_models() - - @property - def af2(self): - """Alias for AlphaFold2""" - return self.alphafold2 - - def _load_models(self): - # Dynamically add model instances as attributes - precludes any drift - models = self.list_models() - for model in models: - model_name = model.id.replace("-", "_") # hyphens out - setattr(self, model_name, model) - - def list_models(self) -> List[FoldModel]: - """list models available for creating folds of your sequences""" - models = [] - for model_id in fold_models_list_get(self.session): - models.append( - FoldModelFactory.create_model(self.session, model_id, default=FoldModel) - ) - return models - - def get_model(self, model_id: str) -> FoldModel: - """ - Get model by model_id. - - FoldModel allows all the usual job manipulation: \ - e.g. making POST and GET requests for this model specifically. - - - Parameters - ---------- - model_id : str - the model identifier - - Returns - ------- - FoldModel - The model - - Raises - ------ - HTTPError - If the GET request does not succeed. - """ - return FoldModelFactory.create_model( - session=self.session, model_id=model_id, default=FoldModel - ) - - def get_results(self, job) -> FoldResultFuture: - """ - Retrieves the results of a fold job. - - Parameters - ---------- - job : Job - The fold job whose results are to be retrieved. - - Returns - ------- - FoldResultFuture - An instance of FoldResultFuture - """ - return FutureFactory.create_future(job=job, session=self.session) + return FoldJob.model_validate(response.json()) diff --git a/openprotein/api/job.py b/openprotein/api/job.py new file mode 100644 index 0000000..a92854c --- /dev/null +++ b/openprotein/api/job.py @@ -0,0 +1,91 @@ +"""Jobs and job-centric flows.""" + +from typing import List + +from openprotein.base import APISession +from openprotein.schemas import Job +from pydantic import TypeAdapter + +# def load_job(session: APISession, job_id: str) -> Future: +# """ +# Reload a Submitted job to resume from where you left off! + + +# Parameters +# ---------- +# session : APISession +# The current API session for communication with the server. +# job_id : str +# The identifier of the job whose details are to be loaded. + +# Returns +# ------- +# Job +# Job + +# Raises +# ------ +# HTTPError +# If the request to the server fails. + +# """ +# return Future.create(session=session, job_id=job_id) + + +def job_args_get(session: APISession, job_id: str) -> dict: + """Get job.""" + endpoint = f"v1/jobs/{job_id}/args" + response = session.get(endpoint) + return dict(**response.json()) + + +def job_get(session: APISession, job_id: str) -> Job: + """Get job.""" + endpoint = f"v1/jobs/{job_id}" + response = session.get(endpoint) + return TypeAdapter(Job).validate_python(response.json()) + + +def jobs_list( + session: APISession, + status: str | None = None, + job_type: str | None = None, + assay_id: str | None = None, + more_recent_than: str | None = None, +) -> List[Job]: + """ + Retrieve a list of jobs filtered by specific criteria. + + Parameters + ---------- + session : APISession + The current API session for communication with the server. + status : str, optional + Filter by job status. If None, jobs of all statuses are retrieved. Default is None. + job_type : str, optional + Filter by Filter. If None, jobs of all types are retrieved. Default is None. + assay_id : str, optional + Filter by assay. If None, jobs for all assays are retrieved. Default is None. + more_recent_than : str, optional + Retrieve jobs that are more recent than a specified date. If None, no date filtering is applied. Default is None. + + Returns + ------- + List[Job] + A list of Job instances that match the specified criteria. + """ + endpoint = "v1/jobs" + + params = {} + if status is not None: + params["status"] = status + if job_type is not None: + params["job_type"] = job_type + if assay_id is not None: + params["assay_id"] = assay_id + if more_recent_than is not None: + params["more_recent_than"] = more_recent_than + + response = session.get(endpoint, params=params) + # return jobs, not futures + return TypeAdapter(List[Job]).validate_python(response.json()) diff --git a/openprotein/api/jobs.py b/openprotein/api/jobs.py deleted file mode 100644 index 113d8dc..0000000 --- a/openprotein/api/jobs.py +++ /dev/null @@ -1,351 +0,0 @@ -# Jobs and job centric flows - - -from typing import List, Union, Optional -import concurrent.futures -import time - -import tqdm -import openprotein.pydantic as pydantic - -from openprotein.base import APISession -import openprotein.config as config -from openprotein.jobs import job_get, ResultsParser, Job -from openprotein.futures import FutureFactory - - -def load_job(session: APISession, job_id: str) -> FutureFactory: - """ - Reload a Submitted job to resume from where you left off! - - - Parameters - ---------- - session : APISession - The current API session for communication with the server. - job_id : str - The identifier of the job whose details are to be loaded. - - Returns - ------- - Job - Job - - Raises - ------ - HTTPError - If the request to the server fails. - - """ - return FutureFactory.create_future(session=session, job_id=job_id) - - -def jobs_list( - session: APISession, - status=None, - job_type=None, - assay_id=None, - more_recent_than=None, -) -> List[Job]: - """ - Retrieve a list of jobs filtered by specific criteria. - - Parameters - ---------- - session : APISession - The current API session for communication with the server. - status : str, optional - Filter by job status. If None, jobs of all statuses are retrieved. Default is None. - job_type : str, optional - Filter by Filter. If None, jobs of all types are retrieved. Default is None. - assay_id : str, optional - Filter by assay. If None, jobs for all assays are retrieved. Default is None. - more_recent_than : str, optional - Retrieve jobs that are more recent than a specified date. If None, no date filtering is applied. Default is None. - - Returns - ------- - List[Job] - A list of Job instances that match the specified criteria. - """ - endpoint = "v1/jobs" - - params = {} - if status is not None: - params["status"] = status - if job_type is not None: - params["job_type"] = job_type - if assay_id is not None: - params["assay_id"] = assay_id - if more_recent_than is not None: - params["more_recent_than"] = more_recent_than - - response = session.get(endpoint, params=params) - # return jobs, not futures - return pydantic.parse_obj_as(List[ResultsParser], response.json()) - - -class JobsAPI: - """API wrapper to get jobs.""" - - # This will continue to get jobs, not futures. - - def __init__(self, session: APISession): - self.session = session - - def list( - self, status=None, job_type=None, assay_id=None, more_recent_than=None - ) -> List[Job]: - """List jobs""" - return jobs_list( - self.session, - status=status, - job_type=job_type, - assay_id=assay_id, - more_recent_than=more_recent_than, - ) - - def get(self, job_id: str, verbose: bool = False) -> Job: - """get Job by ID""" - return load_job(self.session, job_id) - # return job_get(self.session, job_id) - - def __load(self, job_id) -> FutureFactory: - return load_job(self.session, job_id) - - def wait( - self, job: Job, interval=config.POLLING_INTERVAL, timeout=None, verbose=False - ): - return job.wait( - self.session, interval=interval, timeout=timeout, verbose=verbose - ) - - -class AsyncJobFuture: - def __init__(self, session: APISession, job: Union[Job, str]): - if isinstance(job, str): - job = job_get(session, job) - self.session = session - self.job = job - - def refresh(self): - """refresh job status""" - self.job = self.job.refresh(self.session) - - @property - def status(self): - return self.job.status - - @property - def progress(self): - return self.job.progress_counter or 0 - - @property - def num_records(self): - return self.job.num_records - - def done(self): - return self.job.done() - - def cancelled(self): - return self.job.cancelled() - - def get(self, verbose: bool = False): - raise NotImplementedError() - - def wait_until_done( - self, interval=config.POLLING_INTERVAL, timeout=None, verbose=False - ): - """ - Wait for job to complete. Do not fetch results (unlike wait()) - - Args: - interval (int, optional): time between polling. Defaults to config.POLLING_INTERVAL. - timeout (int, optional): max time to wait. Defaults to None. - verbose (bool, optional): verbosity flag. Defaults to False. - - Returns: - results: results of job - """ - job = self.job.wait( - self.session, interval=interval, timeout=timeout, verbose=verbose - ) - self.job = job - return self.done() - - def wait( - self, - interval: int = config.POLLING_INTERVAL, - timeout: Optional[int] = None, - verbose: bool = False, - ): - """ - Wait for job to complete, then fetch results. - - Args: - interval (int, optional): time between polling. Defaults to config.POLLING_INTERVAL. - timeout (int, optional): max time to wait. Defaults to None. - verbose (bool, optional): verbosity flag. Defaults to False. - - Returns: - results: results of job - """ - time.sleep(1) # buffer for BE to register job - job = self.job.wait( - self.session, interval=interval, timeout=timeout, verbose=verbose - ) - self.job = job - return self.get() - - -class StreamingAsyncJobFuture(AsyncJobFuture): - def stream(self): - raise NotImplementedError() - - def get(self, verbose=False) -> List: - generator = self.stream() - if verbose: - total = None - if hasattr(self, "__len__"): - total = len(self) - generator = tqdm.tqdm( - generator, desc="Retrieving", total=total, position=0, mininterval=1.0 - ) - return [entry for entry in generator] - - -class MappedAsyncJobFuture(StreamingAsyncJobFuture): - def __init__( - self, session: APISession, job: Job, max_workers=config.MAX_CONCURRENT_WORKERS - ): - """ - Retrieve results from asynchronous, mapped endpoints. Use `max_workers` > 0 to enable concurrent retrieval of multiple pages. - """ - super().__init__(session, job) - self.max_workers = max_workers - self._cache = {} - - def keys(self): - raise NotImplementedError() - - def get_item(self, k): - raise NotImplementedError() - - def stream_sync(self): - for k in self.keys(): - v = self[k] - yield k, v - - def stream_parallel(self): - num_workers = self.max_workers - - def process(k): - v = self[k] - return k, v - - with concurrent.futures.ThreadPoolExecutor(max_workers=num_workers) as executor: - futures = [] - for k in self.keys(): - if k in self._cache: - yield k, self._cache[k] - else: - f = executor.submit(process, k) - futures.append(f) - - for f in concurrent.futures.as_completed(futures): - yield f.result() - - def stream(self): - if self.max_workers > 0: - return self.stream_parallel() - return self.stream_sync() - - def __getitem__(self, k): - if k in self._cache: - return self._cache[k] - v = self.get_item(k) - self._cache[k] = v - return v - - def __len__(self): - return len(self.keys()) - - def __iter__(self): - return self.stream() - - -class PagedAsyncJobFuture(StreamingAsyncJobFuture): - DEFAULT_PAGE_SIZE = 1024 - - def __init__( - self, - session: APISession, - job: Job, - page_size=None, - num_records=None, - max_workers=config.MAX_CONCURRENT_WORKERS, - ): - """ - Retrieve results from asynchronous, paged endpoints. Use `max_workers` > 0 to enable concurrent retrieval of multiple pages. - """ - if page_size is None: - page_size = self.DEFAULT_PAGE_SIZE - super().__init__(session, job) - self.page_size = page_size - self.max_workers = max_workers - self._num_records = num_records - - def get_slice(self, start, end): - raise NotImplementedError() - - def stream_sync(self): - step = self.page_size - num_returned = step - offset = 0 - while num_returned >= step: - result_page = self.get_slice(offset, offset + step) - for result in result_page: - yield result - num_returned = len(result_page) - offset += num_returned - - # TODO - check the number of results, or store it somehow, so that we don't need - # to check the number of returned entries to see if we're finished (very awkward when using concurrency) - def stream_parallel(self): - step = self.page_size - offset = 0 - - num_workers = self.max_workers - - with concurrent.futures.ThreadPoolExecutor(max_workers=num_workers) as executor: - # submit the paged requests - futures = [] - for _ in range(num_workers * 2): - f = executor.submit(self.get_slice, offset, offset + step) - futures.append(f) - offset += step - - # until we've retrieved all pages (known by retrieving a page with less than the requested number of records) - done = False - while not done: - futures_next = [] - # iterate the futures and submit new requests as needed - for f in concurrent.futures.as_completed(futures): - result_page = f.result() - # check if we're done, meaning the result page is not full - done = done or len(result_page) < step - # if we aren't done, submit another request - if not done: - f = executor.submit(self.get_slice, offset, offset + step) - futures_next.append(f) - offset += step - # yield the results from this page - for result in result_page: - yield result - # update the list of futures and wait on them again - futures = futures_next - - def stream(self): - if self.max_workers > 0: - return self.stream_parallel() - return self.stream_sync() diff --git a/openprotein/api/poet.py b/openprotein/api/poet.py deleted file mode 100644 index 8beaf64..0000000 --- a/openprotein/api/poet.py +++ /dev/null @@ -1,612 +0,0 @@ -from typing import Iterator, Optional, List, Literal, Dict -from openprotein.pydantic import BaseModel, validator -from io import BytesIO -import random -import requests - -from openprotein.base import APISession -from openprotein.api.jobs import AsyncJobFuture, StreamingAsyncJobFuture -import numpy as np -from openprotein.jobs import ResultsParser, Job, register_job_type, JobType -import openprotein.config as config - -from openprotein.errors import ( - InvalidParameterError, - MissingParameterError, - APIError, -) -from openprotein.api.align import csv_stream, AlignFutureMixin -from openprotein.futures import FutureBase, FutureFactory - - -class PoetSSPResult(BaseModel): - sequence: bytes - score: List[float] - name: Optional[str] = None - _n: int = 0 - - @validator("sequence", pre=True, always=True) - def replacename(cls, value): - """rename X0X""" - if "X0X" in str(value): - return b"WT" - return value - - @validator("name", pre=True, always=True) - def incrementname(cls, value): - if value is None: - cls._n += 1 - return f"Mutant{cls._n}" - return value - - -class PoetScoreResult(BaseModel): - sequence: bytes - score: List[float] - name: Optional[str] = None - - -@register_job_type(JobType.poet_score) -class PoetScoreJob(Job): - parent_id: Optional[str] = None - s3prefix: Optional[str] = None - page_size: Optional[int] = None - page_offset: Optional[int] = None - num_rows: Optional[int] = None - result: Optional[List[PoetScoreResult]] = None - n_completed: Optional[int] = None - - job_type: Literal[JobType.poet_score] = JobType.poet_score - - -@register_job_type(JobType.poet_single_site) -class PoetSSPJob(PoetScoreJob): - parent_id: Optional[str] = None - s3prefix: Optional[str] = None - page_size: Optional[int] = None - page_offset: Optional[int] = None - num_rows: Optional[int] = None - result: Optional[List[PoetSSPResult]] = None - n_completed: Optional[int] = None - - job_type: Literal[JobType.poet_single_site] = JobType.poet_single_site - - -@register_job_type(JobType.poet_generate) -class PoetGenerateJob(Job): - parent_id: Optional[str] = None - s3prefix: Optional[str] = None - page_size: Optional[int] = None - page_offset: Optional[int] = None - num_rows: Optional[int] = None - result: Optional[List[PoetScoreResult]] = None - n_completed: Optional[int] = None - - job_type: Literal[JobType.poet_generate] = JobType.poet_generate - - -def poet_score_post( - session: APISession, prompt_id: str, queries: List[bytes] -) -> FutureFactory: - """ - Submits a job to score sequences based on the given prompt. - - Parameters - ---------- - session : APISession - An instance of APISession to manage interactions with the API. - prompt_id : str - The ID of the prompt. - queries : List[str] - A list of query sequences to be scored. - - Raises - ------ - APIError - If there is an issue with the API request. - - Returns - ------- - PoetScoreJob - An object representing the status and results of the scoring job. - """ - endpoint = "v1/poet/score" - - if len(queries) == 0: - raise MissingParameterError("Must include queries for scoring!") - if not prompt_id: - raise MissingParameterError("Must include prompt_id in request!") - - if isinstance(queries[0], str): - queries = [i.encode() for i in queries] - try: - variant_file = BytesIO(b"\n".join(queries)) - params = {"prompt_id": prompt_id} - response = session.post( - endpoint, files={"variant_file": variant_file}, params=params - ) - return FutureFactory.create_future(session=session, response=response) - except Exception as exc: - raise APIError(f"Failed to post poet score: {exc}") from exc - - -def poet_score_get( - session: APISession, job_id, page_size=config.POET_PAGE_SIZE, page_offset=0 -): - """ - Fetch a page of results from a PoET score job. - - Parameters - ---------- - session : APISession - An instance of APISession to manage interactions with the API. - job_id : str - The ID of the PoET scoring job to fetch results from. - page_size : int, optional - The number of results to fetch in a single page. Defaults to config.POET_PAGE_SIZE. - page_offset : int, optional - The offset (number of results) to start fetching results from. Defaults to 0. - - Raises - ------ - APIError - If the provided page size is larger than the maximum allowed page size. - - Returns - ------- - PoetScoreJob - An object representing the PoET scoring job, including its current status and results (if any). - """ - endpoint = "v1/poet/score" - - if page_size > config.POET_MAX_PAGE_SIZE: - raise APIError( - f"Page size must be less than the max for PoET: {config.POET_MAX_PAGE_SIZE}" - ) - - response = session.get( - endpoint, - params={"job_id": job_id, "page_size": page_size, "page_offset": page_offset}, - ) - - # return results to be assembled together - return ResultsParser.parse_obj(response) - - -def poet_single_site_post( - session: APISession, variant, parent_id=None, prompt_id=None -) -> FutureFactory: - """ - Request PoET single-site analysis for a variant. - - This function will mutate every position in the variant to every amino acid and return the scores. - Note that if parent_id is set then it will inherit all prompt properties of that parent. - - Parameters - ---------- - session : APISession - An instance of APISession for API interactions. - variant : str - The variant to analyze. - parent_id : str, optional - The ID of the parent job. Either parent_id or prompt_id must be set. Defaults to None. - prompt_id : str, optional - The ID of the prompt. Either parent_id or prompt_id must be set. Defaults to None. - - Raises - ------ - APIError - If the input parameters are invalid or there is an issue with the API request. - - Returns - ------- - PoetSSPJob - An object representing the status and results of the PoET single-site analysis job. - Note that the input variant score is given as `X0X`. - """ - endpoint = "v1/poet/single_site" - - if (parent_id is None and prompt_id is None) or ( - parent_id is not None and prompt_id is not None - ): - raise InvalidParameterError("Either parent_id or prompt_id must be set.") - - if isinstance(variant, str): - variant = variant.encode() - - params = {"variant": variant} - if prompt_id is not None: - params["prompt_id"] = prompt_id - if parent_id is not None: - params["parent_id"] = parent_id - - try: - response = session.post(endpoint, params=params) - return FutureFactory.create_future(session=session, response=response) - except Exception as exc: - raise APIError(f"Failed to post poet single-site analysis: {exc}") from exc - - -def poet_single_site_get( - session: APISession, job_id: str, page_size: int = 100, page_offset: int = 0 -) -> FutureFactory: - """ - Fetch paged results of a PoET single-site analysis job. - - Parameters - ---------- - session : APISession - An instance of APISession for API interactions. - job_id : str - The ID of the PoET single-site analysis job to fetch results from. - page_size : int, optional - The number of results to fetch in a single page. Defaults to 100. - page_offset : int, optional - The offset (number of results) to start fetching results from. Defaults to 0. - - Raises - ------ - APIError - If there is an issue with the API request. - - Returns - ------- - PoetSSPJob - An object representing the status and results of the PoET single-site analysis job. - """ - endpoint = "v1/poet/single_site" - - params = {"job_id": job_id, "page_size": page_size, "page_offset": page_offset} - - try: - response = session.get(endpoint, params=params) - - except Exception as exc: - raise APIError( - f"Failed to get poet single-site analysis results: {exc}" - ) from exc - # return results to be assembled together - return ResultsParser.parse_obj(response) - - -def poet_generate_post( - session: APISession, - prompt_id: str, - num_samples=100, - temperature=1.0, - topk=None, - topp=None, - max_length=1000, - random_seed=None, -) -> FutureFactory: - """ - Generate protein sequences with a prompt. - - Parameters - ---------- - session : APISession - An instance of APISession for API interactions. - prompt_id : str - The ID of the prompt to generate samples from. - num_samples : int, optional - The number of samples to generate. Defaults to 100. - temperature : float, optional - The temperature for sampling. Higher values produce more random outputs. Defaults to 1.0. - topk : int, optional - The number of top-k residues to consider during sampling. Defaults to None. - topp : float, optional - The cumulative probability threshold for top-p sampling. Defaults to None. - max_length : int, optional - The maximum length of generated proteins. Defaults to 1000. - random_seed : int, optional - Seed for random number generation. Defaults to a random number. - - Raises - ------ - APIError - If there is an issue with the API request. - - Returns - ------- - Job - An object representing the status and information about the generation job. - """ - endpoint = "v1/poet/generate" - - if not (0.1 <= temperature <= 2): - raise InvalidParameterError("The 'temperature' must be between 0.1 and 2.") - if topk: - if not (2 <= topk <= 20): - raise InvalidParameterError("The 'topk' must be between 2 and 22.") - if topp: - if not (0 <= topp <= 1): - raise InvalidParameterError("The 'topp' must be between 0 and 1.") - if random_seed: - if not (0 <= random_seed <= 2**32): - raise InvalidParameterError("The 'random_seed' must be between 0 and 1.") - - if random_seed is None: - random_seed = random.randrange(2**32) - - params = { - "prompt_id": prompt_id, - "generate_n": num_samples, - "temperature": temperature, - "maxlen": max_length, - "seed": random_seed, - } - if topk is not None: - params["topk"] = topk - if topp is not None: - params["topp"] = topp - - try: - response = session.post(endpoint, params=params) - return FutureFactory.create_future(session=session, response=response) - except Exception as exc: - raise APIError(f"Failed to post PoET generation request: {exc}") from exc - - -def poet_generate_get(session: APISession, job_id) -> requests.Response: - """ - Get the results of a PoET generation job. - - Parameters - ---------- - session : APISession - An instance of APISession for API interactions. - job_id : str - Job ID from a poet/generate job. - - Raises - ------ - APIError - If there is an issue with the API request. - - Returns - ------- - requests.Response - The response object containing the results of the PoET generation job. - """ - endpoint = "v1/poet/generate" - - params = {"job_id": job_id} - - try: - response = session.get(endpoint, params=params, stream=True) - return response - except Exception as exc: - raise APIError(f"Failed to get poet generation results: {exc}") from exc - - -class PoetFuture(AlignFutureMixin, AsyncJobFuture): - def _fmt_results(self, results): - # Format results after getting is complete - return [(p.name, p.sequence, np.asarray(p.score)) for p in results] - - def get(self, verbose=False) -> List: - return super().get(verbose=verbose) - - -class PoetScoreFuture(PoetFuture, FutureBase): - """ - Represents a result of a PoET scoring job. - - Attributes - ---------- - session : APISession - An instance of APISession for API interactions. - job : Job - The PoET scoring job. - page_size : int - The number of results to fetch in a single page. - - Methods - ------- - get(verbose=False) - Get the final results of the PoET job. - - """ - - job_type = ["/poet", "/poet/score"] - - def __init__( - self, session: APISession, job: Job, page_size=config.POET_PAGE_SIZE, **kwargs - ): - """ - init a PoetScoreFuture instance. - - Parameters - ---------- - session (APISession): An instance of APISession for API interactions. - job (Job): The PoET scoring job. - page_size (int, optional): The number of results to fetch in a single page. Defaults to config.POET_PAGE_SIZE. - - """ - super().__init__(session, job) - self.page_size = page_size - - def get(self, verbose=False) -> List[tuple]: - """ - Get the final results of the PoET scoring job. - - Parameters - ---------- - verbose : bool, optional - If True, print verbose output. Defaults to False. - - Raises - ------ - APIError - If there is an issue with the API request. - - Returns - ------- - List[PoetScoreResult] - A list of PoetScoreResult objects representing the scoring results. - """ - - job_id = self.job.job_id - step = self.page_size - - results = [] - num_returned = step - offset = 0 - - while num_returned >= step: - try: - response = poet_score_get( - self.session, - job_id, - page_offset=offset, - page_size=step, - ) - results += response.result - num_returned = len(response.result) - offset += num_returned - except APIError as exc: - if verbose: - print(f"Failed to get results: {exc}") - return self._fmt_results(results) - return self._fmt_results(results) - - -class PoetSingleSiteFuture(PoetFuture, FutureBase): - """ - Represents a result of a PoET single-site analysis job. - - Attributes - ---------- - session : APISession - An instance of APISession for API interactions. - job : Job - The PoET scoring job. - page_size : int - The number of results to fetch in a single page. - - Methods - ------- - get(verbose=False) - Get the final results of the PoET job. - - """ - - job_type = "/poet/single_site" - - def _fmt_results(self, results): - # Format results after getting is complete - return {p.sequence: np.asarray(p.score) for p in results} - - def __init__( - self, session: APISession, job: Job, page_size=config.POET_PAGE_SIZE, **kwargs - ): - """ - init a PoetSingleSiteFuture instance. - - Parameters - ---------- - session (APISession): An instance of APISession for API interactions. - job (Job): The PoET single-site analysis job. - page_size (int, optional): The number of results to fetch in a single page. Defaults to config.POET_PAGE_SIZE. - - """ - super().__init__(session, job) - self.page_size = page_size - - def get(self, verbose=False) -> Dict: - """ - Get the results of a PoET single-site analysis job. - - Parameters - ---------- - verbose : bool, optional - If True, print verbose output. Defaults to False. - - Returns - ------- - Dict[bytes, float] - A dictionary mapping mutation codes to scores. - - Raises - ------ - APIError - If there is an issue with the API request. - """ - - job_id = self.job.job_id - step = self.page_size - - results = [] - num_returned = step - offset = 0 - - while num_returned >= step: - try: - response = poet_single_site_get( - self.session, - job_id, - page_offset=offset, - page_size=step, - ) - results += response.result - num_returned = len(response.result) - offset += num_returned - except APIError as exc: - if verbose: - print(f"Failed to get results: {exc}") - return self._fmt_results(results) - return self._fmt_results(results) - - -class PoetGenerateFuture(PoetFuture, StreamingAsyncJobFuture, FutureBase): - """ - Represents a result of a PoET generation job. - - Attributes - ---------- - session : APISession - An instance of APISession for API interactions. - job : Job - The PoET scoring job. - - Methods: - stream() -> Iterator[PoetScoreResult]: - Stream the results of the PoET generation job. - - """ - - job_type = "/poet/generate" - - def stream(self) -> Iterator[PoetScoreResult]: - """ - Stream the results from the response. - - Returns - ------ - PoetScoreResult: Yield - A result object containing the sequence, score, and name. - - Raises - ------ - APIError - If the request fails. - """ - try: - response = poet_generate_get(self.session, self.job.job_id) - for tokens in csv_stream(response): - try: - name, sequence = tokens[:2] - score = [float(s) for s in tokens[2:]] - sequence = sequence.encode() - sample = PoetScoreResult(sequence=sequence, score=score, name=name) - yield self._fmt_results([sample])[0] - except (IndexError, ValueError) as exc: - # Skip malformed or incomplete tokens - print( - f"Skipping malformed or incomplete tokens: {tokens} with {exc}" - ) - except APIError as exc: - print(f"Failed to stream PoET generation results: {exc}") - - def get(self, verbose=False) -> List: - return super().get(verbose=verbose) diff --git a/openprotein/api/predict.py b/openprotein/api/predict.py index 6475e3b..be03636 100644 --- a/openprotein/api/predict.py +++ b/openprotein/api/predict.py @@ -1,109 +1,15 @@ -from typing import Optional, List, Union, Any, Dict, Literal -from openprotein.pydantic import BaseModel, root_validator - from openprotein.base import APISession -from openprotein.api.jobs import AsyncJobFuture -from openprotein.jobs import ResultsParser, Job, register_job_type, JobType, JobStatus -from openprotein.errors import InvalidParameterError, APIError -from openprotein.futures import FutureFactory, FutureBase - - -class SequenceData(BaseModel): - sequence: str - - -class SequenceDataset(BaseModel): - sequences: List[str] - - -class _Prediction(BaseModel): - """Prediction details.""" - - @root_validator(pre=True) - def extract_pred(cls, values): - p = values.pop("properties") - name = list(p.keys())[0] - ymu = p[name]["y_mu"] - yvar = p[name]["y_var"] - p["name"] = name - p["y_mu"] = ymu - p["y_var"] = yvar - - values.update(p) - return values - - model_id: str - model_name: str - y_mu: Optional[float] = None - y_var: Optional[float] = None - name: Optional[str] - - -class Prediction(BaseModel): - """Prediction details.""" - - model_id: str - model_name: str - properties: Dict[str, Dict[str, float]] - - -class PredictJobBase(Job): - # might be none if just fetching - job_id: Optional[str] = None - job_type: str - status: JobStatus - - -@register_job_type(JobType.workflow_predict) -class PredictJob(PredictJobBase): - """Properties about predict job returned via API.""" - - @root_validator(pre=True) - def extract_pred(cls, values): - # Extracting 'predictions' and 'sequences' from the input values - v = values.pop("result") - preds = [i["predictions"] for i in v] - seqs = [i["sequence"] for i in v] - values["result"] = [ - {"sequence": i, "predictions": p} for i, p in zip(seqs, preds) - ] - return values - - class SequencePrediction(BaseModel): - """Sequence prediction.""" - - sequence: str - predictions: List[Prediction] = [] - - result: Optional[List[SequencePrediction]] = None - job_type: str - - -@register_job_type(JobType.worflow_predict_single_site) -class PredictSingleSiteJob(PredictJobBase): - """Properties about single-site prediction job returned via API.""" - - class SequencePrediction(BaseModel): - """Sequence prediction.""" - - position: int - amino_acid: str - # sequence: str - predictions: List[Prediction] = [] - - result: Optional[List[SequencePrediction]] = None - job_type: Literal[JobType.worflow_predict_single_site] = ( - JobType.worflow_predict_single_site - ) +from openprotein.errors import InvalidParameterError +from openprotein.schemas import WorkflowPredictJob, WorkflowPredictSingleSiteJob def _create_predict_job( session: APISession, endpoint: str, payload: dict, - model_ids: Optional[List[str]] = None, - train_job_id: Optional[str] = None, -) -> FutureBase: + model_ids: list[str] | None = None, + train_job_id: str | None = None, +) -> WorkflowPredictJob: """ Creates a Predict request and returns the job object. @@ -156,15 +62,15 @@ def _create_predict_job( payload["train_job_id"] = train_job_id response = session.post(endpoint, json=payload) - return FutureFactory.create_future(session=session, response=response) + return WorkflowPredictJob.model_validate(response.json()) def create_predict_job( session: APISession, - sequences: SequenceDataset, - train_job: Optional[Any] = None, - model_ids: Optional[List[str]] = None, -) -> FutureBase: + sequences: list[str], + train_job_id: str | None = None, + model_ids: list[str] | None = None, +) -> WorkflowPredictJob: """ Creates a predict job with a given set of sequences and a train job. @@ -200,8 +106,7 @@ def create_predict_job( if isinstance(model_ids, str): model_ids = [model_ids] endpoint = "v1/workflow/predict" - payload = {"sequences": sequences.sequences} - train_job_id = train_job.id if train_job is not None else None + payload = {"sequences": sequences} return _create_predict_job( session, endpoint, payload, model_ids=model_ids, train_job_id=train_job_id ) @@ -209,10 +114,10 @@ def create_predict_job( def create_predict_single_site( session: APISession, - sequence: SequenceData, - train_job: Any, - model_ids: Optional[List[str]] = None, -) -> FutureBase: + sequence: str, + train_job_id: str | None = None, + model_ids: list[str] | None = None, +) -> WorkflowPredictJob: """ Creates a predict job for single site mutants with a given sequence and a train job. @@ -244,18 +149,18 @@ def create_predict_single_site( If the response cannot be parsed into a 'Job' object. """ endpoint = "v1/workflow/predict/single_site" - payload = {"sequence": sequence.sequence} + payload = {"sequence": sequence} return _create_predict_job( - session, endpoint, payload, model_ids=model_ids, train_job_id=train_job.id + session, endpoint, payload, model_ids=model_ids, train_job_id=train_job_id ) def get_prediction_results( session: APISession, job_id: str, - page_size: Optional[int] = None, - page_offset: Optional[int] = None, -) -> PredictJob: + page_size: int | None = None, + page_offset: int | None = None, +) -> WorkflowPredictJob: """ Retrieves the results of a Predict job. @@ -289,15 +194,15 @@ def get_prediction_results( response = session.get(endpoint, params=params) # get results to assemble into list - return ResultsParser.parse_obj(response.json()) + return WorkflowPredictJob.model_validate(response.json()) def get_single_site_prediction_results( session: APISession, job_id: str, - page_size: Optional[int] = None, - page_offset: Optional[int] = None, -) -> PredictSingleSiteJob: + page_size: int | None = None, + page_offset: int | None = None, +) -> WorkflowPredictSingleSiteJob: """ Retrieves the results of a single site Predict job. @@ -331,265 +236,4 @@ def get_single_site_prediction_results( response = session.get(endpoint, params=params) # get results to assemble into list - return ResultsParser.parse_obj(response) - - -class PredictFutureMixin: - """ - Class to to retrieve results from a Predict job. - - Attributes - ---------- - session : APISession - APIsession with auth - job : PredictJob - The job object that represents the current Predict job. - - Methods - ------- - get_results(page_size: Optional[int] = None, page_offset: Optional[int] = None) -> Union[PredictSingleSiteJob, PredictJob] - Retrieves results from a Predict job. - """ - - session: APISession - job: PredictJob - id: Optional[str] = None - - def get_results( - self, page_size: Optional[int] = None, page_offset: Optional[int] = None - ) -> Union[PredictSingleSiteJob, PredictJob]: - """ - Retrieves results from a Predict job. - - it uses the appropriate method to retrieve the results based on job_type. - - Parameters - ---------- - page_size : Optional[int], default is None - The number of results to be returned per page. If None, all results are returned. - page_offset : Optional[int], default is None - The number of results to skip. If None, defaults to 0. - - Returns - ------- - Union[PredictSingleSiteJob, PredictJob] - The job object representing the Predict job. The exact type of job depends on the job type. - - Raises - ------ - HTTPError - If the GET request does not succeed. - """ - assert self.id is not None - if "single_site" in self.job.job_type: - return get_single_site_prediction_results( - self.session, self.id, page_size, page_offset - ) - else: - return get_prediction_results(self.session, self.id, page_size, page_offset) - - -class PredictFuture(PredictFutureMixin, AsyncJobFuture, FutureBase): # type: ignore - """Future Job for manipulating results""" - - job_type = [JobType.workflow_predict, JobType.worflow_predict_single_site] - - def __init__(self, session: APISession, job: PredictJob, page_size=1000): - super().__init__(session, job) - self.page_size = page_size - - def __str__(self) -> str: - return str(self.job) - - def __repr__(self) -> str: - return repr(self.job) - - @property - def id(self): - return self.job.job_id - - def _fmt_results(self, results): - properties = set( - list(i["properties"].keys())[0] for i in results[0].dict()["predictions"] - ) - dict_results = {} - for p in properties: - dict_results[p] = {} - for i, r in enumerate(results): - s = r.sequence - props = [i.properties[p] for i in r.predictions if p in i.properties][0] - dict_results[p][s] = {"mean": props["y_mu"], "variance": props["y_var"]} - dict_results - return dict_results - - def _fmt_ssp_results(self, results): - properties = set( - list(i["properties"].keys())[0] for i in results[0].dict()["predictions"] - ) - dict_results = {} - for p in properties: - dict_results[p] = {} - for i, r in enumerate(results): - s = s = f"{r.position+1}{r.amino_acid}" - props = [i.properties[p] for i in r.predictions if p in i.properties][0] - dict_results[p][s] = {"mean": props["y_mu"], "variance": props["y_var"]} - return dict_results - - def get(self, verbose: bool = False) -> Dict: - """ - Get all the results of the predict job. - - Args: - verbose (bool, optional): If True, print verbose output. Defaults False. - - Raises: - APIError: If there is an issue with the API request. - - Returns: - PredictJob: A list of predict objects representing the results. - """ - step = self.page_size - - results: List = [] - num_returned = step - offset = 0 - - while num_returned >= step: - try: - response = self.get_results(page_offset=offset, page_size=step) - assert isinstance(response.result, list) - results += response.result - num_returned = len(response.result) - offset += num_returned - except APIError as exc: - if verbose: - print(f"Failed to get results: {exc}") - - if self.job.job_type == JobType.workflow_predict: - return self._fmt_results(results) - else: - return self._fmt_ssp_results(results) - - -class PredictService: - """interface for calling Predict endpoints""" - - def __init__(self, session: APISession): - """ - Initialize a new instance of the PredictService class. - - Parameters - ---------- - session : APISession - APIsession with auth - """ - self.session = session - - def create_predict_job( - self, - sequences: List, - train_job: Optional[Any] = None, - model_ids: Optional[List[str]] = None, - ) -> PredictFuture: - """ - Creates a new Predict job for a given list of sequences and a trained model. - - Parameters - ---------- - sequences : List - The list of sequences to be used for the Predict job. - train_job : Any - The train job object representing the trained model. - model_ids : List[str], optional - The list of model ids to be used for Predict. Default is None. - - Returns - ------- - PredictFuture - The job object representing the Predict job. - - Raises - ------ - InvalidParameterError - If the sequences are not of the same length as the assay data or if the train job has not completed successfully. - InvalidParameterError - If BOTH train_job and model_ids are specified - InvalidParameterError - If NEITHER train_job or model_ids is specified - APIError - If the backend refuses the job (due to sequence length or invalid inputs) - """ - if train_job is not None: - if train_job.assaymetadata is not None: - if train_job.assaymetadata.sequence_length is not None: - if any( - [ - train_job.assaymetadata.sequence_length != len(s) - for s in sequences - ] - ): - raise InvalidParameterError( - f"Predict sequences length {len(sequences[0])} != training assaydata ({train_job.assaymetadata.sequence_length})" - ) - if not train_job.done(): - print(f"WARNING: training job has status {train_job.status}") - # raise InvalidParameterError( - # f"train job has status {train_job.status.value}, Predict requires status SUCCESS" - # ) - - sequence_dataset = SequenceDataset(sequences=sequences) - return create_predict_job( - self.session, sequence_dataset, train_job, model_ids=model_ids # type: ignore - ) - - def create_predict_single_site( - self, - sequence: str, - train_job: Any, - model_ids: Optional[List[str]] = None, - ) -> PredictFuture: - """ - Creates a new Predict job for single site mutation analysis with a trained model. - - Parameters - ---------- - sequence : str - The sequence for single site analysis. - train_job : Any - The train job object representing the trained model. - model_ids : List[str], optional - The list of model ids to be used for Predict. Default is None. - - Returns - ------- - PredictFuture - The job object representing the Predict job. - - Raises - ------ - InvalidParameterError - If the sequences are not of the same length as the assay data or if the train job has not completed successfully. - InvalidParameterError - If BOTH train_job and model_ids are specified - InvalidParameterError - If NEITHER train_job or model_ids is specified - APIError - If the backend refuses the job (due to sequence length or invalid inputs) - """ - if train_job.assaymetadata is not None: - if train_job.assaymetadata.sequence_length is not None: - if any([train_job.assaymetadata.sequence_length != len(sequence)]): - raise InvalidParameterError( - f"Predict sequences length {len(sequence)} != training assaydata ({train_job.assaymetadata.sequence_length})" - ) - train_job.refresh() - if not train_job.done(): - print(f"WARNING: training job has status {train_job.status}") - # raise InvalidParameterError( - # f"train job has status {train_job.status.value}, Predict requires status SUCCESS" - # ) - - sequence_dataset = SequenceData(sequence=sequence) - return create_predict_single_site( - self.session, sequence_dataset, train_job, model_ids=model_ids # type: ignore - ) + return WorkflowPredictSingleSiteJob.model_validate(response) diff --git a/openprotein/api/predictor.py b/openprotein/api/predictor.py new file mode 100644 index 0000000..364159a --- /dev/null +++ b/openprotein/api/predictor.py @@ -0,0 +1,274 @@ +import io + +import numpy as np +import pandas as pd +from openprotein.base import APISession +from openprotein.schemas import ( + CVJob, + Job, + PredictJob, + PredictMultiJob, + PredictMultiSingleSiteJob, + PredictorMetadata, + PredictSingleSiteJob, + TrainJob, +) +from pydantic import TypeAdapter + +PATH_PREFIX = "v1/predictor" + + +def predictor_list(session: APISession) -> list[PredictorMetadata]: + """ + List trained predictors. + + Parameters + ---------- + session : APISession + Session object for API communication. + + Returns + ------- + list[PredictorMetadata] + List of predictors + """ + endpoint = PATH_PREFIX + response = session.get(endpoint) + return TypeAdapter(list[PredictorMetadata]).validate_python(response.json()) + + +def predictor_get(session: APISession, predictor_id: str) -> PredictorMetadata: + endpoint = PATH_PREFIX + f"/{predictor_id}" + response = session.get(endpoint) + return TypeAdapter(PredictorMetadata).validate_python(response.json()) + + +def predictor_fit_gp_post( + session: APISession, + assay_id: str, + properties: list[str], + feature_type: str, + model_id: str, + reduction: str | None = None, + prompt_id: str | None = None, + name: str | None = None, + description: str | None = None, +) -> Job: + """ + Create SVD fit job. + + Parameters + ---------- + session : APISession + Session object for API communication. + assay_id : str + Assay ID to fit GP on. + properties: list[str] + Properties in the assay to fit the gp on. + feature_type: str + Type of features to use for encoding sequences. PLM or SVD. + model_id : str + Protembed/SVD model to use depending on feature type. + reduction : str | None + Type of embedding reduction to use for computing features. default = None + prompt_id: str | None + Prompt ID if using PoET-based models. + name: str | None + Optional name of predictor model. Randomly generated if not provided. + description: str | None + Optional description to attach to the model. + + Returns + ------- + PredictorTrainJob + """ + endpoint = PATH_PREFIX + "/gp" + + body = { + "dataset": { + "assay_id": assay_id, + "properties": properties, + }, + "features": { + "type": feature_type, + "model_id": model_id, + }, + "kernel": { + "type": "rbf", + # "multitask": True + }, + } + if reduction is not None: + body["features"]["reduction"] = reduction + if prompt_id is not None: + body["features"]["prompt_id"] = prompt_id + if name is not None: + body["name"] = name + if description is not None: + body["description"] = description + + response = session.post(endpoint, json=body) + return TrainJob.model_validate(response.json()) + + +def predictor_delete(session: APISession, predictor_id: str): + raise NotImplementedError() + + +def predictor_crossvalidate_post( + session: APISession, predictor_id: str, n_splits: int | None = None +): + endpoint = PATH_PREFIX + f"/{predictor_id}/crossvalidate" + + params = {} + if n_splits is not None: + params["n_splits"] = n_splits + response = session.post(endpoint, params=params) + + return CVJob.model_validate(response.json()) + + +def predictor_crossvalidate_get(session: APISession, crossvalidate_job_id: str): + endpoint = PATH_PREFIX + f"/crossvalidate/{crossvalidate_job_id}" + + response = session.get(endpoint) + return response.content + + +def predictor_predict_post( + session: APISession, predictor_id: str, sequences: list[bytes] | list[str] +): + endpoint = PATH_PREFIX + f"/{predictor_id}/predict" + + sequences_unicode = [(s if isinstance(s, str) else s.decode()) for s in sequences] + body = { + "sequences": sequences_unicode, + } + response = session.post(endpoint, json=body) + + return PredictJob.model_validate(response.json()) + + +def predictor_predict_single_site_post( + session: APISession, + predictor_id: str, + base_sequence: bytes | str, +): + endpoint = PATH_PREFIX + f"/{predictor_id}/predict_single_site" + + base_sequence = ( + base_sequence.decode() if isinstance(base_sequence, bytes) else base_sequence + ) + body = { + "base_sequence": base_sequence, + } + response = session.post(endpoint, json=body) + + return PredictSingleSiteJob.model_validate(response.json()) + + +def predictor_predict_get_sequences( + session: APISession, prediction_job_id: str +) -> list[bytes]: + endpoint = PATH_PREFIX + f"/predict/{prediction_job_id}/sequences" + + response = session.get(endpoint) + return TypeAdapter(list[bytes]).validate_python(response.json()) + + +def predictor_predict_get_sequence_result( + session: APISession, prediction_job_id: str, sequence: bytes | str +) -> bytes: + """ + Get encoded result for a sequence from the request ID. + + Parameters + ---------- + session : APISession + Session object for API communication. + job_id : str + job ID to retrieve results from + sequence from: bytes + sequence to retrieve results for + + Returns + ------- + result : bytes + """ + if isinstance(sequence, bytes): + sequence = sequence.decode() + endpoint = PATH_PREFIX + f"/predict/{prediction_job_id}/{sequence}" + response = session.get(endpoint) + return response.content + + +def predictor_predict_get_batched_result( + session: APISession, prediction_job_id: str +) -> bytes: + """ + Get encoded result for a sequence from the request ID. + + Parameters + ---------- + session : APISession + Session object for API communication. + prediction_job_id : str + job ID to retrieve results from + sequence : bytes + sequence to retrieve results for + + Returns + ------- + result : bytes + """ + endpoint = PATH_PREFIX + f"/predict/{prediction_job_id}" + response = session.get(endpoint) + return response.content + + +def decode_predict(data: bytes, batched: bool = False) -> tuple[np.ndarray, np.ndarray]: + """ + Decode prediction scores. + + Args: + data (bytes): raw bytes encoding the array received over the API + batched (bool): whether or not the result was batched. affects the retrieved csv format whether they contain additional columns and header rows. + + Returns: + mus (np.ndarray): decoded array of means + vars (np.ndarray): decoded array of variances + """ + s = io.BytesIO(data) + if batched: + # should contain header and sequence column + df = pd.read_csv(s) + scores = df.iloc[:, 1:].values + else: + # should be a single row with 2n columns + df = pd.read_csv(s, header=None) + scores = df.values + mus = scores[:, ::2] + vars = scores[:, 1::2] + return mus, vars + + +def decode_crossvalidate(data: bytes) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Decode crossvalidate scores. + + Args: + data (bytes): raw bytes encoding the array received over the API + + Returns: + mus (np.ndarray): decoded array of means + vars (np.ndarray): decoded array of variances + """ + s = io.BytesIO(data) + # should contain header and sequence column + df = pd.read_csv(s) + scores = df.values + # row_num, seq, measurement_name, y, y_mu, y_var + y = scores[:, 3] + mus = scores[:, 4] + vars = scores[:, 5] + return y, mus, vars diff --git a/openprotein/api/svd.py b/openprotein/api/svd.py new file mode 100644 index 0000000..f739c38 --- /dev/null +++ b/openprotein/api/svd.py @@ -0,0 +1,193 @@ +import io + +import numpy as np +from openprotein.base import APISession +from openprotein.errors import InvalidParameterError +from openprotein.schemas import FitJob, SVDEmbeddingsJob, SVDMetadata +from pydantic import TypeAdapter + +PATH_PREFIX = "v1/embeddings/svd" + + +def svd_list_get(session: APISession) -> list[SVDMetadata]: + """Get SVD job metadata for all SVDs. Including SVD dimension and sequence lengths.""" + endpoint = PATH_PREFIX + response = session.get(endpoint) + return TypeAdapter(list[SVDMetadata]).validate_python(response.json()) + + +def svd_get(session: APISession, svd_id: str) -> SVDMetadata: + """Get SVD job metadata. Including SVD dimension and sequence lengths.""" + endpoint = PATH_PREFIX + f"/{svd_id}" + response = session.get(endpoint) + return SVDMetadata(**response.json()) + + +def svd_get_sequences(session: APISession, svd_id: str) -> list[bytes]: + """ + Get sequences used to fit an SVD. + + Parameters + ---------- + session : APISession + Session object for API communication. + svd_id : str + SVD ID whose sequences to fetch + + Returns + ------- + sequences : List[bytes] + """ + endpoint = PATH_PREFIX + f"/{svd_id}/sequences" + response = session.get(endpoint) + return TypeAdapter(list[bytes]).validate_python(response.json()) + + +def embed_get_sequence_result( + session: APISession, job_id: str, sequence: str | bytes +) -> bytes: + """ + Get encoded svd embeddings result for a sequence from the request ID. + + Parameters + ---------- + session : APISession + Session object for API communication. + job_id : str + job ID to retrieve results from + sequence : bytes + sequence to retrieve results for + + Returns + ------- + result : bytes + """ + if isinstance(sequence, bytes): + sequence = sequence.decode() + endpoint = PATH_PREFIX + f"/embed/{job_id}/{sequence}" + response = session.get(endpoint) + return response.content + + +def embed_decode(data: bytes) -> np.ndarray: + """ + Decode embedding. + + Args: + data (bytes): raw bytes encoding the array received over the API + + Returns: + np.ndarray: decoded array + """ + s = io.BytesIO(data) + return np.load(s, allow_pickle=False) + + +def svd_delete(session: APISession, svd_id: str): + """ + Delete and SVD model. + + Parameters + ---------- + session : APISession + Session object for API communication. + svd_id : str + SVD model to delete + + Returns + ------- + bool + """ + + endpoint = PATH_PREFIX + f"/{svd_id}" + session.delete(endpoint) + return True + + +def svd_fit_post( + session: APISession, + model_id: str, + sequences: list[bytes] | list[str] | None = None, + assay_id: str | None = None, + n_components: int = 1024, + reduction: str | None = None, + prompt_id: str | None = None, +) -> FitJob: + """ + Create SVD fit job. + + Parameters + ---------- + session : APISession + Session object for API communication. + model_id : str + model to use + sequences : list[bytes] | None, optional + Optional sequences to fit SVD with. Either use sequences or assay_id. sequences is preferred. + assay_id: str | None, optional + Optional ID of assay containing sequences to fit SVD with. Either use sequences or assay_id. Ignored if sequences are provided. + n_components : int + number of SVD components to fit. default = 1024 + reduction : str | None + embedding reduction to use for fitting the SVD. default = None + + Returns + ------- + Job + """ + + endpoint = PATH_PREFIX + + body = { + "model_id": model_id, + "n_components": n_components, + } + if reduction is not None: + body["reduction"] = reduction + if prompt_id is not None: + body["prompt_id"] = prompt_id + if sequences is not None: + # both provided + if assay_id is not None: + raise InvalidParameterError("Expected only either sequences or assay_id") + sequences = [(s if isinstance(s, str) else s.decode()) for s in sequences] + body["sequences"] = sequences + else: + # both are none + if assay_id is None: + raise InvalidParameterError("Expected either sequences or assay_id") + body["assay_id"] = assay_id + + response = session.post(endpoint, json=body) + # return job for metadata + return FitJob.model_validate(response.json()) + + +def svd_embed_post( + session: APISession, svd_id: str, sequences: list[bytes] | list[str] +) -> SVDEmbeddingsJob: + """ + POST a request for embeddings from the given SVD model. + + Parameters + ---------- + session : APISession + Session object for API communication. + svd_id : str + SVD model to use + sequences : List[bytes] + sequences to SVD + + Returns + ------- + Job + """ + endpoint = PATH_PREFIX + f"/{svd_id}/embed" + + sequences_unicode = [(s if isinstance(s, str) else s.decode()) for s in sequences] + body = { + "sequences": sequences_unicode, + } + response = session.post(endpoint, json=body) + + return SVDEmbeddingsJob.model_validate(response.json()) diff --git a/openprotein/api/train.py b/openprotein/api/train.py index 0186006..4f64a27 100644 --- a/openprotein/api/train.py +++ b/openprotein/api/train.py @@ -1,48 +1,9 @@ -from typing import Optional, List, Union -from openprotein.pydantic import BaseModel - -import openprotein.pydantic as pydantic from openprotein.base import APISession -from openprotein.api.jobs import AsyncJobFuture, Job -from openprotein.futures import FutureFactory, FutureBase - -from openprotein.errors import InvalidParameterError, APIError, InvalidJob -from openprotein.api.data import AssayDataset, AssayMetadata -from openprotein.jobs import JobType -from openprotein.api.predict import PredictService, PredictFuture -from datetime import datetime - - -class CVItem(BaseModel): - row_index: int - sequence: str - measurement_name: str - y: float - y_mu: float - y_var: float - - -class CVResults(Job): - num_rows: int - page_size: int - page_offset: int - result: List[CVItem] +from openprotein.errors import InvalidJob +from openprotein.schemas import Job, WorkflowCVJob, WorkflowTrainJob -class TrainStep(BaseModel): - step: int - loss: float - tag: str - tags: dict - - -class TrainGraph(BaseModel): - traingraph: List[TrainStep] - created_date: datetime - job_id: str - - -def list_models(session: APISession, job_id: str) -> List: +def list_models(session: APISession, job_id: str) -> list: """ List models assoicated with job @@ -79,15 +40,15 @@ def crossvalidate(session: APISession, train_job_id: str, n_splits: int = 5) -> response = session.post( endpoint, json={"train_job_id": train_job_id, "n_splits": n_splits} ) - return pydantic.parse_obj_as(Job, response.json()) + return Job.model_validate(response.json()) def get_crossvalidation( session: APISession, job_id: str, - page_size: Optional[int] = None, - page_offset: Optional[int] = 0, -) -> CVResults: + page_size: int | None = None, + page_offset: int | None = 0, +) -> WorkflowCVJob: """ Get CV results @@ -103,16 +64,16 @@ def get_crossvalidation( response = session.get(endpoint, params=params) if response.status_code == 404: raise InvalidJob("No CV job has been submitted for this job!") - return pydantic.parse_obj_as(CVResults, response.json()) + return WorkflowCVJob.model_validate(response.json()) def _train_job( session: APISession, endpoint: str, - assaydataset: AssayDataset, - measurement_name: Union[str, List[str]], + assay_id: str, + measurement_name: str | list[str], model_name: str = "", - force_preprocess: Optional[bool] = False, + force_preprocess: bool = False, ) -> Job: """ Create a training job. @@ -150,37 +111,24 @@ def _train_job( HTTPError If the request to the server fails. """ - if not isinstance(assaydataset, AssayDataset): - raise InvalidParameterError("assaydataset should be an assaydata Job result") - if isinstance(measurement_name, str): - measurement_name = [measurement_name] - - for measurement in measurement_name: - if measurement not in assaydataset.measurement_names: - raise InvalidParameterError(f"No {measurement} in measurement names") - if assaydataset.shape[0] < 3: - raise InvalidParameterError("Assaydata must have >=3 data points for training!") - if model_name is None: - model_name = "" data = { - "assay_id": assaydataset.id, + "assay_id": assay_id, "measurement_name": measurement_name, "model_name": model_name, } params = {"force_preprocess": str(force_preprocess).lower()} response = session.post(endpoint, params=params, json=data) - response.raise_for_status() - return FutureFactory.create_future(session=session, response=response) + return Job.model_validate(response.json()) def create_train_job( session: APISession, - assaydataset: AssayDataset, - measurement_name: Union[str, List[str]], + assay_id: str, + measurement_name: str | list[str], model_name: str = "", - force_preprocess: Optional[bool] = False, + force_preprocess: bool = False, ): """ Create a training job. @@ -220,442 +168,55 @@ def create_train_job( """ endpoint = "v1/workflow/train" return _train_job( - session, endpoint, assaydataset, measurement_name, model_name, force_preprocess + session=session, + endpoint=endpoint, + assay_id=assay_id, + measurement_name=measurement_name, + model_name=model_name, + force_preprocess=force_preprocess, ) def _create_train_job_br( session: APISession, - assaydataset: AssayDataset, - measurement_name: Union[str, List[str]], + assay_id: str, + measurement_name: str | list[str], model_name: str = "", - force_preprocess: Optional[bool] = False, + force_preprocess: bool = False, ): """Alias for create_train_job""" endpoint = "v1/workflow/train/br" return _train_job( - session, endpoint, assaydataset, measurement_name, model_name, force_preprocess + session=session, + endpoint=endpoint, + assay_id=assay_id, + measurement_name=measurement_name, + model_name=model_name, + force_preprocess=force_preprocess, ) def _create_train_job_gp( session: APISession, - assaydataset: AssayDataset, - measurement_name: Union[str, List[str]], + assay_id: str, + measurement_name: str | list[str], model_name: str = "", - force_preprocess: Optional[bool] = False, + force_preprocess: bool = False, ): """Alias for create_train_job""" endpoint = "v1/workflow/train/gp" return _train_job( - session, endpoint, assaydataset, measurement_name, model_name, force_preprocess + session=session, + endpoint=endpoint, + assay_id=assay_id, + measurement_name=measurement_name, + model_name=model_name, + force_preprocess=force_preprocess, ) -def get_training_results(session: APISession, job_id: str) -> TrainGraph: +def get_training_results(session: APISession, job_id: str) -> WorkflowTrainJob: """Get Training results (e.g. loss etc) of job.""" endpoint = f"v1/workflow/train/{job_id}" response = session.get(endpoint) - return TrainGraph(**response.json()) - - -class CVFutureMixin: - """ - A mixin class to provide cross-validation job submission and retrieval. - - Attributes - ---------- - session : APISession - The session object to use for API communication. - train_job_id : str - The id of the training job associated with this cross-validation job. - job : Job - The Job object for this cross-validation job. - - Methods - ------- - crossvalidate(): - Submits a cross-validation job to the server. - get_crossvalidation(page_size: Optional[int] = None, page_offset: Optional[int] = 0): - Retrieves the results of the cross-validation job. - """ - - session: APISession - train_job_id: str - job: Job - - def crossvalidate(self): - """ - Submit a cross-validation job to the server. - - Returns - ------- - Job - The Job object for this cross-validation job. - - """ - self.job = crossvalidate(self.session, self.train_job_id) - return self.job - - def get_crossvalidation( - self, page_size: Optional[int] = None, page_offset: Optional[int] = 0 - ): - """ - Retrieves the results of the cross-validation job. - - - Parameters - ---------- - page_size : int, optional - The number of items to retrieve in a single request.. - page_offset : int, optional - The offset to start retrieving items from. Default is 0. - - Returns - ------- - dict - The results of the cross-validation job. - - """ - return get_crossvalidation( - self.session, self.job.job_id, page_size, page_offset - ) - - -class CVFuture(CVFutureMixin, AsyncJobFuture, FutureBase): - """ - This class helps initiating, submitting, and retrieving the - results of a cross-validation job. - - Attributes - ---------- - session : APISession - The session object to use for API communication. - train_job_id : str - The id of the training job associated with this cross-validation job. - job : Job - The Job object for this cross-validation job. - page_size : int - The number of items to retrieve in a single request. - - """ - - job_type = [JobType.workflow_crossvalidate] - - def __init__(self, session: APISession, train_job_id: str, job: Job = None): - """ - Constructs a new CVFuture instance. - - Parameters - ---------- - session : APISession - The session object to use for API communication. - train_job_id : str - The id of the training job associated with this cross-validation job. - job : Job, optional - The Job object for this cross-validation job. - """ - super().__init__(session, job) - self.train_job_id = train_job_id - self.page_size = 1000 - - def __str__(self) -> str: - return str(self.job) - - def __repr__(self) -> str: - return repr(self.job) - - @property - def id(self): - return self.job.job_id - - def _fmt_results(self, results): - return [i.dict() for i in results] - - def get(self, verbose: bool = False) -> List: - """ - Get all the results of the CV job. - - Args: - verbose (bool, optional): If True, print verbose output. Defaults False. - - Raises: - APIError: If there is an issue with the API request. - - Returns: - PredictJob: A list of predict objects representing the results. - """ - step = self.page_size - - results = [] - num_returned = step - offset = 0 - - while num_returned >= step: - try: - response = self.get_crossvalidation(page_offset=offset, page_size=step) - results += response.result - num_returned = len(response.result) - offset += num_returned - except APIError as exc: - if verbose: - print(f"Failed to get results: {exc}") - return self._fmt_results(results) - return self._fmt_results(results) - - -class TrainFutureMixin: - """ - This class provides functionality for retrieving the - results of a training job and initiating cross-validation jobs. - - Attributes - ---------- - session : APISession - The session object to use for API communication. - job : Job - The Job object for this training job. - - Methods - ------- - get_results() -> TrainGraph: - Returns the results of the training job. - crossvalidate(): - Submits a cross-validation job and returns it. - """ - - session: APISession - job: Job - - def _fmt_results(self, results): - train_dict = {} - tags = set([i.tag for i in results.traingraph]) - for tag in tags: - train_dict[tag] = [ - i.loss for i in results.traingraph if i.dict()["tag"] == tag - ] - return train_dict - - def get_results(self) -> TrainGraph: - """ - Gets the results of the training job. - - Returns - ------- - TrainGraph - The results of the training job. - """ - results = get_training_results(self.session, self.job.job_id) - return self._fmt_results(results) - - def crossvalidate(self): - """ - Submits a cross-validation job. - - If a cross-validation job has already been created, it returns that job. - Otherwise, it creates a new cross-validation job and returns it. - - Returns - ------- - CVFuture - The cross-validation job associated with this training job. - """ - cv = CVFuture(self.session, train_job_id=self.job.job_id) - job = cv.crossvalidate() # noqa: F841 - return cv - - def list_models(self): - """ - List models assoicated with job - - Parameters - ---------- - session : APISession - Session object for API communication. - job_id : str - job ID - - Returns - ------- - List - List of models - """ - return list_models(self.session, self.job.job_id) - - -class TrainFuture(TrainFutureMixin, AsyncJobFuture, FutureBase): - """Future Job for manipulating results""" - - job_type = [JobType.workflow_train] - - def __init__( - self, - session: APISession, - job: Job, - assaymetadata: Optional[AssayMetadata] = None, - ): - super().__init__(session, job) - self.assaymetadata = assaymetadata - self._predict = PredictService(session) - - def predict( - self, sequences: List[str], model_ids: Optional[List[str]] = None - ) -> PredictFuture: - """ - Creates a predict job based on the training job. - - Parameters - ---------- - sequences : List[str] - The list of sequences to be used for the Predict job. - model_ids : List[str], optional - The list of model ids to be used for Predict. Default is None. - - Returns - ------- - PredictFuture - The job object representing the Predict job. - """ - return self._predict.create_predict_job(sequences, self, model_ids=model_ids) - - def predict_single_site( - self, - sequence: str, - model_ids: Optional[List[str]] = None, - ) -> PredictFuture: - """ - Creates a new Predict job for single site mutation analysis with a trained model. - - Parameters - ---------- - sequence : str - The sequence for single site analysis. - train_job : Any - The train job object representing the trained model. - model_ids : List[str], optional - The list of model ids to be used for Predict. Default is None. - - Returns - ------- - PredictFuture - The job object representing the Predict job. - - Creates a predict job based on the training job - """ - return self._predict.create_predict_single_site( - sequence, self, model_ids=model_ids - ) - - def __str__(self) -> str: - return str(self.job) - - def __repr__(self) -> str: - return repr(self.job) - - @property - def id(self): - return self.job.job_id - - def get(self, verbose: bool = False) -> TrainGraph: - try: - results = self.get_results() - except APIError as exc: - if verbose: - print(f"Failed to get results: {exc}") - raise exc - return results - - -class TrainingAPI: - """API interface for calling Train endpoints""" - - def __init__( - self, - session: APISession, - ): - self.session = session - self.assay = None - - def create_training_job( - self, - assaydataset: AssayDataset, - measurement_name: Union[str, List[str]], - model_name: str = "", - force_preprocess: Optional[bool] = False, - ) -> TrainFuture: - """ - Create a training job on your data. - - This function validates the inputs, formats the data, and sends the job. - - Parameters - ---------- - assaydataset : AssayDataset - An AssayDataset object from which the assay_id is extracted. - measurement_name : str or List[str] - The name(s) of the measurement(s) to be used in the training job. - model_name : str, optional - The name to give the model. - force_preprocess : bool, optional - If set to True, preprocessing is forced even if data already exists. - - Returns - ------- - TrainFuture - A TrainFuture Job - - Raises - ------ - InvalidParameterError - If the `assaydataset` is not an AssayDataset object, - If any measurement name provided does not exist in the AssayDataset, - or if the AssayDataset has fewer than 3 data points. - HTTPError - If the request to the server fails. - """ - if isinstance(measurement_name, str): - measurement_name = [measurement_name] - return create_train_job( - self.session, assaydataset, measurement_name, model_name, force_preprocess - ) - - def _create_training_job_br( - self, - assaydataset: AssayDataset, - measurement_name: Union[str, List[str]], - model_name: str = "", - force_preprocess: Optional[bool] = False, - ) -> TrainFuture: - """Same as create_training_job.""" - return _create_train_job_br( - self.session, assaydataset, measurement_name, model_name, force_preprocess - ) - - def _create_training_job_gp( - self, - assaydataset: AssayDataset, - measurement_name: Union[str, List[str]], - model_name: str = "", - force_preprocess: Optional[bool] = False, - ) -> TrainFuture: - """Same as create_training_job.""" - return _create_train_job_gp( - self.session, assaydataset, measurement_name, model_name, force_preprocess - ) - - def get_training_results(self, job_id: str) -> TrainFuture: - """ - Get training results (e.g. loss etc). - - Parameters - ---------- - job_id : str - job_id to get - - - Returns - ------- - TrainFuture - A TrainFuture Job - """ - return get_training_results(self.session, job_id) + return WorkflowTrainJob.model_validate(response.json()) diff --git a/openprotein/app/__init__.py b/openprotein/app/__init__.py new file mode 100644 index 0000000..a27c5f6 --- /dev/null +++ b/openprotein/app/__init__.py @@ -0,0 +1,11 @@ +from .services import ( + SVDAPI, + AlignAPI, + AssayDataAPI, + DesignAPI, + EmbeddingsAPI, + FoldAPI, + JobsAPI, + PredictorAPI, + TrainingAPI, +) diff --git a/openprotein/app/models/__init__.py b/openprotein/app/models/__init__.py new file mode 100644 index 0000000..11dfc4c --- /dev/null +++ b/openprotein/app/models/__init__.py @@ -0,0 +1,20 @@ +"""OpenProtein app-level models providing service-level functionality.""" + +from .align import MSAFuture, PromptFuture +from .assaydata import AssayDataPage, AssayDataset, AssayMetadata +from .deprecated.poet import PoetGenerateFuture, PoetScoreFuture, PoetSingleSiteFuture +from .design import DesignFuture +from .embeddings import ( + EmbeddingModel, + EmbeddingResultFuture, + EmbeddingsScoreResultFuture, + ESMModel, + OpenProteinModel, + PoETModel, +) +from .fold import AlphaFold2Model, ESMFoldModel, FoldModel, FoldResultFuture +from .futures import Future, MappedFuture, StreamingFuture +from .predict import PredictFuture +from .predictor import PredictionResultFuture, PredictorModel +from .svd import SVDModel +from .train import CVFuture, TrainFuture diff --git a/openprotein/app/models/align/__init__.py b/openprotein/app/models/align/__init__.py new file mode 100644 index 0000000..9f6f910 --- /dev/null +++ b/openprotein/app/models/align/__init__.py @@ -0,0 +1,5 @@ +"""App-level models for align.""" + +from .base import AlignFuture +from .msa import MSAFuture +from .prompt import PromptFuture diff --git a/openprotein/app/models/align/base.py b/openprotein/app/models/align/base.py new file mode 100644 index 0000000..7c5d6bd --- /dev/null +++ b/openprotein/app/models/align/base.py @@ -0,0 +1,20 @@ +from openprotein.api import align +from openprotein.base import APISession +from openprotein.schemas import Job, PoetInputType + + +class AlignFuture: + session: APISession + job: Job + + def get_input(self, input_type: PoetInputType): + """See child function docs.""" + return align.get_input(self.session, self.job, input_type) + + def get_seed(self): + """See child function docs.""" + return align.get_seed(self.session, self.job) + + @property + def id(self): + return self.job.job_id diff --git a/openprotein/app/models/align/msa.py b/openprotein/app/models/align/msa.py new file mode 100644 index 0000000..214b05f --- /dev/null +++ b/openprotein/app/models/align/msa.py @@ -0,0 +1,133 @@ +from typing import Iterator + +from openprotein import config +from openprotein.api import align +from openprotein.base import APISession +from openprotein.schemas import JobType, MSAJob, MSASamplingMethod + +from ..futures import Future, InvalidFutureError +from .base import AlignFuture +from .prompt import PromptFuture + + +class MSAFuture(AlignFuture, Future): + """ + Represents a result of a MSA job. + + Attributes + ---------- + session : APISession + An instance of APISession for API interactions. + job : Job + The PoET scoring job. + page_size : int + The number of results to fetch in a single page. + + Methods + ------- + get(verbose=False) + Get the final results of the PoET scoring job. + + Returns + ------- + List[PoetScoreResult] + The list of results from the PoET scoring job. + """ + + job: MSAJob + + def __init__( + self, session: APISession, job: MSAJob, page_size: int = config.POET_PAGE_SIZE + ): + """ + init a PoetScoreFuture instance. + + Parameters + ---------- + session : APISession + An instance of APISession for API interactions. + job : Job + The PoET scoring job. + page_size : int + The number of results to fetch in a single page. + + """ + super().__init__(session, job) + self.page_size = page_size + self.msa_id = self.job.job_id + + # def wait(self, verbose: bool = False): + # _ = self.job.wait( + # self.session, + # interval=config.POLLING_INTERVAL, + # timeout=config.POLLING_TIMEOUT, + # verbose=verbose, + # ) # no progress to track + # return self.get() + + def get(self, verbose: bool = False) -> Iterator[list[str]]: + return align.get_msa(self.session, self.job) + + def sample_prompt( + self, + num_sequences: int | None = None, + num_residues: int | None = None, + method: MSASamplingMethod = MSASamplingMethod.NEIGHBORS_NONGAP_NORM_NO_LIMIT, + homology_level: float = 0.8, + max_similarity: float = 1.0, + min_similarity: float = 0.0, + always_include_seed_sequence: bool = False, + num_ensemble_prompts: int = 1, + random_seed: int | None = None, + ) -> PromptFuture: + """ + Create a protein sequence prompt from a linked MSA (Multiple Sequence Alignment) for PoET Jobs. + + Parameters + ---------- + num_sequences : int, optional + Maximum number of sequences in the prompt. Must be <100. + num_residues : int, optional + Maximum number of residues (tokens) in the prompt. Must be less than 24577. + method : MSASamplingMethod, optional + Method to use for MSA sampling. Defaults to NEIGHBORS_NONGAP_NORM_NO_LIMIT. + homology_level : float, optional + Level of homology for sequences in the MSA (neighbors methods only). Must be between 0 and 1. Defaults to 0.8. + max_similarity : float, optional + Maximum similarity between sequences in the MSA and the seed. Must be between 0 and 1. Defaults to 1.0. + min_similarity : float, optional + Minimum similarity between sequences in the MSA and the seed. Must be between 0 and 1. Defaults to 0.0. + always_include_seed_sequence : bool, optional + Whether to always include the seed sequence in the MSA. Defaults to False. + num_ensemble_prompts : int, optional + Number of ensemble jobs to run. Defaults to 1. + random_seed : int, optional + Seed for random number generation. Defaults to a random number between 0 and 2**32-1. + + Raises + ------ + InvalidParameterError + If provided parameter values are not in the allowed range. + MissingParameterError + If both or none of 'num_sequences', 'num_residues' is specified. + + Returns + ------- + PromptJob + """ + msa_id = self.msa_id + job = align.prompt_post( + self.session, + msa_id=msa_id, + num_sequences=num_sequences, + num_residues=num_residues, + method=method, + homology_level=homology_level, + max_similarity=max_similarity, + min_similarity=min_similarity, + always_include_seed_sequence=always_include_seed_sequence, + num_ensemble_prompts=num_ensemble_prompts, + random_seed=random_seed, + ) + future = PromptFuture.create(session=self.session, job=job) + return future diff --git a/openprotein/app/models/align/prompt.py b/openprotein/app/models/align/prompt.py new file mode 100644 index 0000000..bae7b95 --- /dev/null +++ b/openprotein/app/models/align/prompt.py @@ -0,0 +1,78 @@ +from typing import Iterator + +from openprotein import config +from openprotein.api import align +from openprotein.api import job as job_api +from openprotein.base import APISession +from openprotein.schemas import PromptJob + +from ..futures import Future +from .base import AlignFuture + + +class PromptFuture(AlignFuture, Future): + """ + Represents a result of a prompt job. + + Attributes + ---------- + session : APISession + An instance of APISession for API interactions. + job : Job + The PoET scoring job. + page_size : int + The number of results to fetch in a single page. + + Methods + ------- + get(verbose=False) + Get the final results of the PoET scoring job. + + Returns + ------- + List[PoetScoreResult] + The list of results from the PoET scoring job. + """ + + job: PromptJob + + def __init__( + self, + session: APISession, + job: PromptJob, + page_size: int = config.POET_PAGE_SIZE, + msa_id: str | None = None, + ): + """ + init a PoetScoreFuture instance. + + Parameters + ---------- + session (APISession): An instance of APISession for API interactions. + job (Job): The PoET scoring job. + page_size (int, optional): The number of results to fetch in a single page. Defaults to config.POET_PAGE_SIZE. + + """ + super().__init__(session, job) + self.page_size = page_size + + if msa_id is None: + msa_id = job_api.job_args_get(self.session, job.job_id).get("root_msa") + self._msa_id = msa_id + self.prompt_id = self.job.job_id + + # def wait(self, verbose: bool = False, **kwargs) -> Iterator[list[str]]: + # _ = self.job.wait( + # session=self.session, + # interval=config.POLLING_INTERVAL, + # timeout=config.POLLING_TIMEOUT, + # verbose=verbose, + # ) # no progress to track + # return self.get(verbose=verbose, **kwargs) + + def get( + self, prompt_index: int | None = None, verbose: bool = False + ) -> Iterator[list[str]]: + return align.get_prompt( + session=self.session, job=self.job, prompt_index=prompt_index + ) diff --git a/openprotein/app/models/assaydata.py b/openprotein/app/models/assaydata.py new file mode 100644 index 0000000..1876baf --- /dev/null +++ b/openprotein/app/models/assaydata.py @@ -0,0 +1,176 @@ +import pandas as pd +from openprotein import config +from openprotein.api import assaydata +from openprotein.base import APISession +from openprotein.errors import APIError +from openprotein.schemas import AssayDataPage, AssayMetadata + + +class AssayDataset: + """Future Job for manipulating results""" + + def __init__(self, session: APISession, metadata: AssayMetadata): + """ + init for AssayDataset. + + Parameters + ---------- + session : APISession + Session object for API communication. + metadata : AssayMetadata + Metadata object of the assay data. + """ + self.session = session + self.metadata = metadata + self.page_size = config.BASE_PAGE_SIZE + if self.page_size > 1000: + self.page_size = 1000 + + def __str__(self) -> str: + return str(self.metadata) + + def __repr__(self) -> str: + return repr(self.metadata) + + @property + def id(self): + return self.metadata.assay_id + + @property + def name(self): + return self.metadata.assay_name + + @property + def description(self): + return self.metadata.assay_description + + @property + def measurement_names(self): + return self.metadata.measurement_names + + @property + def sequence_length(self): + return self.metadata.sequence_length + + def __len__(self): + return self.metadata.num_rows + + @property + def shape(self): + return (len(self), len(self.measurement_names) + 1) + + def list_models(self): + """ + List models assoicated with assay. + + Returns + ------- + List + List of models + """ + return assaydata.list_models(self.session, self.id) + + def update( + self, assay_name: str | None = None, assay_description: str | None = None + ) -> None: + """ + Update the assay metadata. + + Parameters + ---------- + assay_name : str, optional + New name of the assay, by default None. + assay_description : str, optional + New description of the assay, by default None. + + Returns + ------- + None + """ + metadata = assaydata.assaydata_put( + self.session, + self.id, + assay_name=assay_name, + assay_description=assay_description, + ) + self.metadata = metadata + + def _get_all(self, verbose: bool = False) -> pd.DataFrame: + """ + Get all assay data. + + Returns + ------- + pd.DataFrame + Dataframe containing all assay data. + """ + step = self.page_size + + results = [] + num_returned = step + offset = 0 + + while num_returned >= step: + try: + result = self.get_slice(offset, offset + step) + results.append(result) + num_returned = len(result) + offset += num_returned + except APIError as exc: + if verbose: + print(f"Failed to get results: {exc}") + return pd.concat(results) + return pd.concat(results) + + def get_first(self) -> pd.DataFrame: + """ + Get head slice of assay data. + + Returns + ------- + pd.DataFrame + Dataframe containing the slice of assay data. + """ + rows = [] + entries = assaydata.assaydata_page_get( + self.session, self.id, page_offset=0, page_size=1 + ) + for row in entries.assaydata: + row = [row.mut_sequence] + row.measurement_values + rows.append(row) + table = pd.DataFrame(rows, columns=["sequence"] + self.measurement_names) # type: ignore + return table + + def get_slice(self, start: int, end: int) -> pd.DataFrame: + """ + Get a slice of assay data. + + Parameters + ---------- + start : int + Start index of the slice. + end : int + End index of the slice. + + Returns + ------- + pd.DataFrame + Dataframe containing the slice of assay data. + """ + rows = [] + page_size = self.page_size + # loop over the range + for i in range(start, end, page_size): + # the last page might be smaller than the page size + current_page_size = min(page_size, end - i) + + entries = assaydata.assaydata_page_get( + self.session, self.id, page_offset=i, page_size=current_page_size + ) + + for row in entries.assaydata: + row = [row.mut_sequence] + row.measurement_values + rows.append(row) + + table = pd.DataFrame(rows, columns=["sequence"] + self.measurement_names) # type: ignore + return table diff --git a/openprotein/app/models/deprecated/__init__.py b/openprotein/app/models/deprecated/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openprotein/app/models/deprecated/poet.py b/openprotein/app/models/deprecated/poet.py new file mode 100644 index 0000000..8c73c24 --- /dev/null +++ b/openprotein/app/models/deprecated/poet.py @@ -0,0 +1,183 @@ +from typing import Collection, Iterator + +import numpy as np +from openprotein import config +from openprotein.api import align, poet +from openprotein.base import APISession +from openprotein.errors import APIError +from openprotein.schemas import ( + PoetGenerateJob, + PoetScoreJob, + PoetScoreResult, + PoetSSPJob, + PoetSSPResult, +) + +from ..futures import Future, PagedFuture, StreamingFuture + + +class PoetScoreFuture(PagedFuture, Future): + """ + Represents a result of a PoET scoring job. + + Attributes + ---------- + session : APISession + An instance of APISession for API interactions. + job : Job + The PoET scoring job. + page_size : int + The number of results to fetch in a single page. + + Methods + ------- + get(verbose=False) + Get the final results of the PoET job. + + """ + + job: PoetScoreJob + + def __init__( + self, + session: APISession, + job: PoetScoreJob, + page_size=config.POET_PAGE_SIZE, + **kwargs, + ): + """ + init a PoetScoreFuture instance. + + Parameters + ---------- + session (APISession): An instance of APISession for API interactions. + job (Job): The PoET scoring job. + page_size (int, optional): The number of results to fetch in a single page. Defaults to config.POET_PAGE_SIZE. + + """ + super().__init__(session, job) + self.page_size = page_size + + def _fmt_results(self, results: list[PoetScoreResult]): + # Format results after getting is complete + return [(p.name, p.sequence, np.asarray(p.score)) for p in results] + + def get_slice(self, start: int, end: int, **kwargs) -> Collection: + results = poet.poet_score_get( + self.session, + self.id, + page_offset=start, + page_size=end - start, + ).result + if results is not None: + return self._fmt_results(results) + return [] + + +class PoetSingleSiteFuture(PagedFuture, Future): + """ + Represents a result of a PoET single-site analysis job. + + Attributes + ---------- + session : APISession + An instance of APISession for API interactions. + job : Job + The PoET scoring job. + page_size : int + The number of results to fetch in a single page. + + Methods + ------- + get(verbose=False) + Get the final results of the PoET job. + + """ + + job: PoetSSPJob + + def __init__( + self, + session: APISession, + job: PoetSSPJob, + page_size=config.POET_PAGE_SIZE, + **kwargs, + ): + """ + init a PoetSingleSiteFuture instance. + + Parameters + ---------- + session (APISession): An instance of APISession for API interactions. + job (Job): The PoET single-site analysis job. + page_size (int, optional): The number of results to fetch in a single page. Defaults to config.POET_PAGE_SIZE. + + """ + super().__init__(session, job) + self.page_size = page_size + + def _fmt_results(self, results: list[PoetSSPResult]): + # Format results after getting is complete + return {p.sequence: np.asarray(p.score) for p in results} + + def get_slice(self, start: int, end: int, **kwargs) -> Collection: + results = poet.poet_single_site_get( + self.session, + self.id, + page_offset=start, + page_size=end - start, + ).result + if results is not None: + return self._fmt_results(results) + return [] + + +class PoetGenerateFuture(StreamingFuture, Future): + """ + Represents a result of a PoET generation job. + + Attributes + ---------- + session : APISession + An instance of APISession for API interactions. + job : Job + The PoET scoring job. + + Methods: + stream() -> Iterator[PoetScoreResult]: + Stream the results of the PoET generation job. + + """ + + job: PoetGenerateJob + + def stream(self) -> Iterator[PoetScoreResult]: + """ + Stream the results from the response. + + Returns + ------ + PoetScoreResult: Yield + A result object containing the sequence, score, and name. + + Raises + ------ + APIError + If the request fails. + """ + try: + response = poet.poet_generate_get(self.session, self.job.job_id) + for tokens in align.csv_stream(response): + try: + name, sequence = tokens[:2] + score = [float(s) for s in tokens[2:]] + sequence = sequence.encode() + sample = PoetScoreResult(sequence=sequence, score=score, name=name) + yield sample + except (IndexError, ValueError) as exc: + # Skip malformed or incomplete tokens + print( + f"Skipping malformed or incomplete tokens: {tokens} with {exc}" + ) + except APIError as exc: + print(f"Failed to stream PoET generation results: {exc}") diff --git a/openprotein/app/models/design.py b/openprotein/app/models/design.py new file mode 100644 index 0000000..0b8bacd --- /dev/null +++ b/openprotein/app/models/design.py @@ -0,0 +1,108 @@ +from openprotein.api import design +from openprotein.base import APISession +from openprotein.schemas import DesignJob, DesignResults, DesignStep + +from .futures import Future, PagedFuture + + +class DesignFuture(PagedFuture, Future): + """Future Job for manipulating results""" + + job: DesignJob + + def __init__(self, session: APISession, job: DesignJob, page_size: int = 1000): + super().__init__(session, job) + self.page_size = page_size + + def __str__(self) -> str: + return str(self.job) + + def __repr__(self) -> str: + return repr(self.job) + + def _fmt_results( + self, results: DesignResults + ) -> ( + # list[dict] + list[DesignStep] + ): + # return [i.model_dump() for i in results.result] + return results.result + + @property + def id(self): + return self.job.job_id + + # def get(self, step: int | None = None, verbose: bool = False) -> list[dict]: + # """ + # Get all the results of the design job. + + # Args: + # verbose (bool, optional): If True, print verbose output. Defaults False. + + # Raises: + # APIError: If there is an issue with the API request. + + # Returns: + # List: A list of predict objects representing the results. + # """ + # page = self.page_size + + # results = [] + # num_returned = page + # offset = 0 + + # while num_returned >= page: + # try: + # response = self.get_results( + # page_offset=offset, step=step, page_size=page + # ) + # results += response.result + # num_returned = len(response.result) + # offset += num_returned + # except APIError as exc: + # if verbose: + # print(f"Failed to get results: {exc}") + # return self._fmt_results(results) + + def get_slice(self, start: int, end: int, step: int | None = None, **kwargs): + results = self.get_results( + page_offset=start, page_size=end - start, step=step, **kwargs + ) + return self._fmt_results(results) + + def get_results( + self, + step: int | None = None, + page_size: int | None = None, + page_offset: int | None = None, + ) -> DesignResults: + """ + Retrieves the results of a Design job. + + This function retrieves the results of a Design job by making a GET request to design.. + + Parameters + ---------- + page_size : Optional[int], default is None + The number of results to be returned per page. If None, all results are returned. + page_offset : Optional[int], default is None + The number of results to skip. If None, defaults to 0. + + Returns + ------- + DesignJob + The job object representing the Design job. + + Raises + ------ + HTTPError + If the GET request does not succeed. + """ + return design.get_design_results( + self.session, + job_id=self.job.job_id, + step=step, + page_size=page_size, + page_offset=page_offset, + ) diff --git a/openprotein/app/models/embeddings/__init__.py b/openprotein/app/models/embeddings/__init__.py new file mode 100644 index 0000000..c66158b --- /dev/null +++ b/openprotein/app/models/embeddings/__init__.py @@ -0,0 +1,7 @@ +"""App-level models for Embeddings.""" + +from .base import EmbeddingModel +from .esm import ESMModel +from .future import EmbeddingResultFuture, EmbeddingsScoreResultFuture +from .openprotein import OpenProteinModel +from .poet import PoETModel diff --git a/openprotein/app/models/embeddings/base.py b/openprotein/app/models/embeddings/base.py new file mode 100644 index 0000000..65121e7 --- /dev/null +++ b/openprotein/app/models/embeddings/base.py @@ -0,0 +1,284 @@ +from typing import TYPE_CHECKING + +from openprotein.api import assaydata, embedding, predictor, svd +from openprotein.base import APISession +from openprotein.errors import InvalidParameterError +from openprotein.schemas import FeatureType, ModelMetadata, ReductionType + +from ..assaydata import AssayDataset, AssayMetadata +from .future import EmbeddingResultFuture + +if TYPE_CHECKING: + from ..predictor import PredictorModel + from ..svd import SVDModel + + +class EmbeddingModel: + + # overridden by subclasses + # get correct emb model + model_id: list[str] | str = "protembed" + + def __init__( + self, session: APISession, model_id: str, metadata: ModelMetadata | None = None + ): + self.session = session + self.id = model_id + self._metadata = metadata + self.__doc__ = self.__fmt_doc() + + def __fmt_doc(self): + summary = str(self.metadata.description.summary) + return f"""\t{summary} + \t max_sequence_length = {self.metadata.max_sequence_length} + \t supported outputs = {self.metadata.output_types} + \t supported tokens = {self.metadata.input_tokens} + """ + + def __str__(self) -> str: + return self.id + + def __repr__(self) -> str: + return self.id + + @classmethod + def get_model(cls): + if isinstance(cls.model_id, str): + return [cls.model_id] + return cls.model_id + + @classmethod + def create( + cls, + session: APISession, + model_id: str, + default: type["EmbeddingModel"] | None = None, + ): + """ + Create and return an instance of the appropriate Future class based on the job type. + + Returns: + - An instance of the appropriate Future class. + """ + # Dynamically discover all subclasses of EmbeddingModel + model_classes = EmbeddingModel.__subclasses__() + + # Find the EmbeddingModel class that matches the model_id + for model_class in model_classes: + if model_id in model_class.get_model(): + return model_class(session=session, model_id=model_id) + # default to ProtembedModel + if default is not None: + try: + return default(session=session, model_id=model_id) + except: + # continue to throw error as unsupported + pass + raise ValueError(f"Unsupported model_id type: {model_id}") + + @property + def metadata(self): + if self._metadata is None: + self._metadata = self.get_metadata() + return self._metadata + + def get_metadata(self) -> ModelMetadata: + """ + Get model metadata for this model. + + Returns + ------- + ModelMetadata + """ + if self._metadata is not None: + return self._metadata + self._metadata = embedding.get_model(self.session, self.id) + return self._metadata + + def embed( + self, + sequences: list[bytes] | list[str], + reduction: ReductionType | None = ReductionType.MEAN, + **kwargs, + ) -> EmbeddingResultFuture: + """ + Embed sequences using this model. + + Parameters + ---------- + sequences : List[bytes] + sequences to SVD + reduction: ReductionType | None, Optional + embeddings reduction to use (e.g. mean) + + Returns + ------- + EmbeddingResultFuture + """ + return EmbeddingResultFuture.create( + session=self.session, + job=embedding.request_post( + session=self.session, + model_id=self.id, + sequences=sequences, + reduction=reduction, + **kwargs, + ), + sequences=sequences, + ) + + def logits( + self, sequences: list[bytes] | list[str], **kwargs + ) -> EmbeddingResultFuture: + """ + logit embeddings for sequences using this model. + + Parameters + ---------- + sequences : List[bytes] + sequences to SVD + + Returns + ------- + EmbeddingResultFuture + """ + return EmbeddingResultFuture.create( + session=self.session, + job=embedding.request_logits_post( + session=self.session, model_id=self.id, sequences=sequences, **kwargs + ), + sequences=sequences, + ) + + def attn( + self, sequences: list[bytes] | list[str], **kwargs + ) -> EmbeddingResultFuture: + """ + Attention embeddings for sequences using this model. + + Parameters + ---------- + sequences : List[bytes] + sequences to SVD + + Returns + ------- + EmbeddingResultFuture + """ + return EmbeddingResultFuture.create( + session=self.session, + job=embedding.request_attn_post( + session=self.session, model_id=self.id, sequences=sequences, **kwargs + ), + sequences=sequences, + ) + + def fit_svd( + self, + sequences: list[bytes] | list[str] | None = None, + assay: AssayDataset | None = None, + n_components: int = 1024, + reduction: ReductionType | None = None, + **kwargs, + ) -> "SVDModel": + """ + Fit an SVD on the embedding results of this model. + + This function will create an SVDModel based on the embeddings from this model \ + as well as the hyperparameters specified in the args. + + Parameters + ---------- + sequences : List[bytes] + sequences to SVD + n_components: int + number of components in SVD. Will determine output shapes + reduction: ReductionType | None + embeddings reduction to use (e.g. mean) + + Returns + ------- + SVDModel + """ + # local import for cyclic dep + from ..svd import SVDModel + + # Ensure either or + if (assay is None and sequences is None) or ( + assay is not None and sequences is not None + ): + raise InvalidParameterError( + "Expected either assay or sequences to fit SVD on!" + ) + model_id = self.id + job = svd.svd_fit_post( + session=self.session, + model_id=model_id, + sequences=sequences, + assay_id=assay.id if assay is not None else None, + n_components=n_components, + reduction=reduction, + **kwargs, + ) + return SVDModel.create(session=self.session, job=job) + + def fit_gp( + self, + assay: AssayMetadata | AssayDataset | str, + properties: list[str], + reduction: ReductionType, + name: str | None = None, + description: str | None = None, + **kwargs, + ) -> "PredictorModel": + """ + Fit a GP on assay using this embedding model and hyperparameters. + + Parameters + ---------- + assay : AssayMetadata | str + Assay to fit GP on. + properties: list[str] + Properties in the assay to fit the gp on. + reduction : str + Type of embedding reduction to use for computing features. PLM must use reduction. + + Returns + ------- + PredictorModel + """ + # local import to resolve cyclic + from ..predictor import PredictorModel + + model_id = self.id + # get assay if str + assay = ( + assaydata.get_assay_metadata(session=self.session, assay_id=assay) + if isinstance(assay, str) + else assay + ) + # extract assay_id + assay_id = assay.assay_id if isinstance(assay, AssayMetadata) else assay.id + if len(properties) == 0: + raise InvalidParameterError("Expected (at-least) 1 property to train") + if not set(properties) <= set(assay.measurement_names): + raise InvalidParameterError( + f"Expected all provided properties to be a subset of assay's measurements: {assay.measurement_names}" + ) + # TODO - support multitask + if len(properties) > 1: + raise InvalidParameterError( + "Training a multitask GP is not yet supported (i.e. number of properties should only be 1 for now)" + ) + job = predictor.predictor_fit_gp_post( + session=self.session, + assay_id=assay_id, + properties=properties, + feature_type=FeatureType.PLM, + model_id=model_id, + reduction=reduction, + name=name, + description=description, + **kwargs, + ) + return PredictorModel.create(session=self.session, job=job) diff --git a/openprotein/app/models/embeddings/esm.py b/openprotein/app/models/embeddings/esm.py new file mode 100644 index 0000000..1f5662d --- /dev/null +++ b/openprotein/app/models/embeddings/esm.py @@ -0,0 +1,32 @@ +"""Community-based ESM models.""" + +from .base import EmbeddingModel + + +class ESMModel(EmbeddingModel): + """ + Class providing inference endpoints for Facebook's ESM protein language Models. + + Examples + -------- + View specific model details (inc supported tokens) with the `?` operator. + + .. code-block:: python + + import openprotein + session = openprotein.connect(username="user", password="password") + session.embedding.esm2_t12_35M_UR50D?""" + + model_id = [ + "esm1b_t33_650M_UR50S", + "esm1v_t33_650M_UR90S_1", + "esm1v_t33_650M_UR90S_2", + "esm1v_t33_650M_UR90S_3", + "esm1v_t33_650M_UR90S_4", + "esm1v_t33_650M_UR90S_5", + "esm2_t12_35M_UR50D", + "esm2_t30_150M_UR50D", + "esm2_t33_650M_UR50D", + "esm2_t36_3B_UR50D", + "esm2_t6_8M_UR50D", + ] diff --git a/openprotein/app/models/embeddings/future.py b/openprotein/app/models/embeddings/future.py new file mode 100644 index 0000000..f032ab9 --- /dev/null +++ b/openprotein/app/models/embeddings/future.py @@ -0,0 +1,119 @@ +"""Future for embeddings-related jobs.""" + +from collections import namedtuple +from typing import Generator + +import numpy as np + +from openprotein import config +from openprotein.api import embedding +from openprotein.base import APISession +from openprotein.schemas import ( + AttnJob, + EmbeddingsJob, + GenerateJob, + JobType, + LogitsJob, + ScoreJob, + ScoreSingleSiteJob, +) + +from ..futures import Future, MappedFuture, StreamingFuture + + +class EmbeddingResultFuture(MappedFuture, Future): + """Future for manipulating results for embeddings-related requests.""" + + job: EmbeddingsJob | AttnJob | LogitsJob + + def __init__( + self, + session: APISession, + job: EmbeddingsJob | AttnJob | LogitsJob, + sequences: list[bytes] | list[str] | None = None, + max_workers: int = config.MAX_CONCURRENT_WORKERS, + ): + super().__init__(session=session, job=job, max_workers=max_workers) + self._sequences = sequences + + def get(self, verbose=False) -> list: + return super().get(verbose=verbose) + + @property + def sequences(self) -> list[bytes] | list[str]: + if self._sequences is None: + self._sequences = embedding.get_request_sequences( + self.session, self.job.job_id + ) + return self._sequences + + @property + def id(self): + return self.job.job_id + + def keys(self): + return self.sequences + + def get_item(self, sequence: bytes) -> np.ndarray: + """ + Get embedding results for specified sequence. + + Args: + sequence (bytes): sequence to fetch results for + + Returns: + np.ndarray: embeddings + """ + data = embedding.request_get_sequence_result( + self.session, self.job.job_id, sequence + ) + return embedding.result_decode(data) + + +class EmbeddingsScoreResultFuture(StreamingFuture, Future): + """Future for manipulating results for embeddings score-related requests.""" + + job: ScoreJob | ScoreSingleSiteJob | GenerateJob + + def __init__( + self, + session: APISession, + job: ScoreJob | ScoreSingleSiteJob | GenerateJob, + sequences: list[bytes] | list[str] | None = None, + ): + super().__init__(session=session, job=job) + self._sequences = sequences + + @property + def sequences(self) -> list[bytes] | list[str]: + if isinstance(self.job, GenerateJob): + raise Exception("generate job does not support listing sequences") + if self._sequences is None: + self._sequences = embedding.get_request_sequences( + self.session, self.job.job_id + ) + return self._sequences + + def stream(self) -> Generator: + if self.job_type == JobType.poet_generate: + stream = embedding.request_get_generate_result( + session=self.session, job_id=self.id + ) + else: + stream = embedding.request_get_score_result( + session=self.session, job_id=self.id + ) + # mut_code, ... for ssp + # name, sequence, ... for score + header = next(stream) + score_start_index = 0 + for i, col_name in enumerate(header): + if col_name.startswith("score"): + score_start_index = i + break + Score = namedtuple("Score", header[:score_start_index] + ["score"]) + for line in stream: + # combine scores into numpy array + scores = np.array([float(s) for s in line[score_start_index:]]) + output = Score(*line[:score_start_index], scores) + yield output diff --git a/openprotein/app/models/embeddings/openprotein.py b/openprotein/app/models/embeddings/openprotein.py new file mode 100644 index 0000000..39b1c6e --- /dev/null +++ b/openprotein/app/models/embeddings/openprotein.py @@ -0,0 +1,21 @@ +"""OpenProtein-proprietary models.""" + +from .base import EmbeddingModel + + +class OpenProteinModel(EmbeddingModel): + """ + Class providing inference endpoints for proprietary protein embedding models served by OpenProtein. + + Examples + -------- + View specific model details (inc supported tokens) with the `?` operator. + + .. code-block:: python + + import openprotein + session = openprotein.connect(username="user", password="password") + session.embedding.prot_seq? + """ + + model_id = ["prot-seq", "rotaprot-large-uniref50w", "rotaprot_large_uniref90_ft"] diff --git a/openprotein/app/models/embeddings/poet.py b/openprotein/app/models/embeddings/poet.py new file mode 100644 index 0000000..43a68ad --- /dev/null +++ b/openprotein/app/models/embeddings/poet.py @@ -0,0 +1,408 @@ +from typing import TYPE_CHECKING + +from openprotein.api import embedding, poet +from openprotein.base import APISession +from openprotein.schemas import ( + ModelMetadata, + PoetGenerateJob, + PoetScoreJob, + PoetSSPJob, + ReductionType, +) + +from ..align import PromptFuture +from ..assaydata import AssayDataset, AssayMetadata +from ..deprecated.poet import PoetGenerateFuture, PoetScoreFuture, PoetSingleSiteFuture +from ..futures import Future +from .base import EmbeddingModel +from .future import EmbeddingResultFuture, EmbeddingsScoreResultFuture + +if TYPE_CHECKING: + from ..predictor import PredictorModel + from ..svd import SVDModel + + +class PoETModel(EmbeddingModel): + """ + Class for OpenProtein's foundation model PoET - NB. PoET functions are dependent on a prompt supplied via the align endpoints. + + Examples + -------- + View specific model details (inc supported tokens) with the `?` operator. + + .. code-block:: python + + import openprotein + session = openprotein.connect(username="user", password="password") + session.embedding.poet. + + + """ + + model_id = "poet" + + # TODO - Add model to explicitly require prompt_id + def __init__( + self, session: APISession, model_id: str, metadata: ModelMetadata | None = None + ): + self.session = session + self.id = model_id + self._metadata = metadata + self.deprecated = self.Deprecated(session=session) + # could add prompt here? + + def embed( + self, + prompt: str | PromptFuture, + sequences: list[bytes], + reduction: ReductionType | None = ReductionType.MEAN, + ) -> EmbeddingResultFuture: + """ + Embed sequences using this model. + + Parameters + ---------- + prompt: Union[str, PromptFuture] + prompt from an align workflow to condition Poet model + sequence : bytes + Sequence to embed. + reduction: str + embeddings reduction to use (e.g. mean) + Returns + ------- + EmbeddingResultFuture + """ + prompt_id = prompt.id if isinstance(prompt, PromptFuture) else prompt + return super().embed( + sequences=sequences, reduction=reduction, prompt_id=prompt_id + ) + + def logits( + self, + prompt: str | PromptFuture, + sequences: list[bytes], + ) -> EmbeddingResultFuture: + """ + logit embeddings for sequences using this model. + + Parameters + ---------- + prompt: Union[str, PromptFuture] + prompt from an align workflow to condition Poet model + sequence : bytes + Sequence to analyse. + + Returns + ------- + EmbeddingResultFuture + """ + prompt_id = prompt.id if isinstance(prompt, PromptFuture) else prompt + return super().logits( + sequences=sequences, + prompt_id=prompt_id, + ) + + def attn(self): + """Not Available for Poet.""" + raise ValueError("Attn not yet supported for poet") + + def score( + self, prompt: str | PromptFuture, sequences: list[bytes] + ) -> EmbeddingsScoreResultFuture: + """ + Score query sequences using the specified prompt. + + Parameters + ---------- + prompt: Union[str, PromptFuture] + prompt from an align workflow to condition Poet model + sequence : bytes + Sequence to analyse. + Returns + ------- + ScoreFuture + """ + prompt_id = prompt.id if isinstance(prompt, PromptFuture) else prompt + return EmbeddingsScoreResultFuture.create( + session=self.session, + job=embedding.request_score_post( + session=self.session, + model_id=self.id, + prompt_id=prompt_id, + sequences=sequences, + ), + ) + + def single_site( + self, prompt: str | PromptFuture, sequence: bytes + ) -> EmbeddingsScoreResultFuture: + """ + Score all single substitutions of the query sequence using the specified prompt. + + Parameters + ---------- + prompt: Union[str, PromptFuture] + prompt from an align workflow to condition Poet model + sequence : bytes + Sequence to analyse. + Returns + ------- + results + The scores of the mutated sequence. + """ + prompt_id = prompt.id if isinstance(prompt, PromptFuture) else prompt + return EmbeddingsScoreResultFuture.create( + session=self.session, + job=embedding.request_score_single_site_post( + session=self.session, + model_id=self.id, + base_sequence=sequence, + prompt_id=prompt_id, + ), + ) + + def generate( + self, + prompt: str | PromptFuture, + num_samples: int = 100, + temperature: float = 1.0, + topk: float | None = None, + topp: float | None = None, + max_length: int = 1000, + seed: int | None = None, + ) -> EmbeddingsScoreResultFuture: + """ + Generate protein sequences conditioned on a prompt. + + Parameters + ---------- + prompt: Union[str, PromptFuture] + prompt from an align workflow to condition Poet model + num_samples : int, optional + The number of samples to generate, by default 100. + temperature : float, optional + The temperature for sampling. Higher values produce more random outputs, by default 1.0. + topk : int, optional + The number of top-k residues to consider during sampling, by default None. + topp : float, optional + The cumulative probability threshold for top-p sampling, by default None. + max_length : int, optional + The maximum length of generated proteins, by default 1000. + seed : int, optional + Seed for random number generation, by default a random number. + + Raises + ------ + APIError + If there is an issue with the API request. + + Returns + ------- + Job + An object representing the status and information about the generation job. + """ + prompt_id = prompt.id if isinstance(prompt, PromptFuture) else prompt + return EmbeddingsScoreResultFuture.create( + session=self.session, + job=embedding.request_generate_post( + session=self.session, + model_id=self.id, + num_samples=num_samples, + temperature=temperature, + topk=topk, + topp=topp, + max_length=max_length, + random_seed=seed, + prompt_id=prompt_id, + ), + ) + + def fit_svd( + self, + prompt: str | PromptFuture, + sequences: list[bytes] | list[str] | None = None, + assay: AssayDataset | None = None, + n_components: int = 1024, + reduction: ReductionType | None = None, + ) -> "SVDModel": + """ + Fit an SVD on the embedding results of this model. + + This function will create an SVDModel based on the embeddings from this model \ + as well as the hyperparameters specified in the args. + + Parameters + ---------- + prompt: Union[str, PromptFuture] + prompt from an align workflow to condition Poet model + sequences : List[bytes] + sequences to SVD + n_components: int + number of components in SVD. Will determine output shapes + reduction: str + embeddings reduction to use (e.g. mean) + + + Returns + ------- + SVDModel + """ + prompt_id = prompt.id if isinstance(prompt, PromptFuture) else prompt + return super().fit_svd( + sequences=sequences, + assay=assay, + n_components=n_components, + reduction=reduction, + prompt_id=prompt_id, + ) + + def fit_gp( + self, + prompt: str | PromptFuture, + assay: AssayMetadata | AssayDataset | str, + properties: list[str], + **kwargs, + ) -> "PredictorModel": + """ + Fit a GP on assay using this embedding model and hyperparameters. + + Parameters + ---------- + assay : AssayMetadata | str + Assay to fit GP on. + properties: list[str] + Properties in the assay to fit the gp on. + reduction : str + Type of embedding reduction to use for computing features. PLM must use reduction. + + Returns + ------- + PredictorModel + """ + prompt_id = prompt.id if isinstance(prompt, PromptFuture) else prompt + return super().fit_gp( + assay=assay, properties=properties, prompt_id=prompt_id, **kwargs + ) + + class Deprecated: + + def __init__(self, session: APISession): + self.session = session + + def score( + self, + prompt: str | PromptFuture, + sequences: list[bytes], + ) -> PoetScoreFuture: + """ + Score query sequences using the specified prompt. + + Parameters + ---------- + prompt: Union[str, PromptFuture] + prompt from an align workflow to condition Poet model + sequence : bytes + Sequence to analyse. + Returns + ------- + PoetScoreFuture + """ + prompt_id = prompt.id if isinstance(prompt, PromptFuture) else prompt + # HACK - manually construct the job and future since job types have been overwritten + return PoetScoreFuture( + session=self.session, + job=PoetScoreJob( + **poet.poet_score_post( + session=self.session, + prompt_id=prompt_id, + queries=sequences, + ).model_dump() + ), + ) + + def single_site( + self, prompt: str | PromptFuture, sequence: bytes + ) -> PoetSingleSiteFuture: + """ + Score query sequences using the specified prompt. + + Parameters + ---------- + prompt: Union[str, PromptFuture] + prompt from an align workflow to condition Poet model + sequence : bytes + Sequence to analyse. + Returns + ------- + ScoreFuture + """ + prompt_id = prompt.id if isinstance(prompt, PromptFuture) else prompt + # HACK - manually construct the job and future since job types have been overwritten + return PoetSingleSiteFuture( + session=self.session, + job=PoetSSPJob( + **poet.poet_single_site_post( + session=self.session, + prompt_id=prompt_id, + variant=sequence, + ).model_dump() + ), + ) + + def generate( + self, + prompt: str | PromptFuture, + num_samples: int = 100, + temperature: float = 1.0, + topk: float | None = None, + topp: float | None = None, + max_length: int = 1000, + seed: int | None = None, + ) -> PoetGenerateFuture: + """ + (Deprecated) Generate protein sequences conditioned on a prompt. + + Parameters + ---------- + prompt: Union[str, PromptFuture] + prompt from an align workflow to condition Poet model + num_samples : int, optional + The number of samples to generate, by default 100. + temperature : float, optional + The temperature for sampling. Higher values produce more random outputs, by default 1.0. + topk : int, optional + The number of top-k residues to consider during sampling, by default None. + topp : float, optional + The cumulative probability threshold for top-p sampling, by default None. + max_length : int, optional + The maximum length of generated proteins, by default 1000. + seed : int, optional + Seed for random number generation, by default a random number. + + Raises + ------ + APIError + If there is an issue with the API request. + + Returns + ------- + Job + An object representing the status and information about the generation job. + """ + prompt_id = prompt.id if isinstance(prompt, PromptFuture) else prompt + # HACK - manually construct the job and future since job types have been overwritten + return PoetGenerateFuture( + session=self.session, + job=PoetGenerateJob( + **poet.poet_generate_post( + session=self.session, + prompt_id=prompt_id, + num_samples=num_samples, + temperature=temperature, + topk=topk, + topp=topp, + max_length=max_length, + random_seed=seed, + ).model_dump() + ), + ) diff --git a/openprotein/app/models/embeddings/test.py b/openprotein/app/models/embeddings/test.py new file mode 100644 index 0000000..921c77e --- /dev/null +++ b/openprotein/app/models/embeddings/test.py @@ -0,0 +1,38 @@ +from math import pi +from typing import Protocol + + +class Shape(Protocol): + def get_area(self) -> float: ... + + def get_perimeter(self) -> float: ... + + +class Circle: + def __init__(self, radius) -> None: + self.radius = radius + + def get_area(self) -> float: + return pi * self.radius**2 + + def get_perimeter(self) -> float: + return 2 * pi * self.radius + + +class Square: + def __init__(self, side) -> None: + self.side = side + + def get_area(self) -> float: + return self.side**2 + + +def print_shape_info(shape: Shape): + print(f"Area: {shape.get_area()}") + print(f"Perimeter: {shape.get_perimeter()}") + + +circle = Circle(10) +square = Square(5) +print_shape_info(circle) +print_shape_info(square) diff --git a/openprotein/app/models/fold/__init__.py b/openprotein/app/models/fold/__init__.py new file mode 100644 index 0000000..3d5c669 --- /dev/null +++ b/openprotein/app/models/fold/__init__.py @@ -0,0 +1,6 @@ +"""Application models for Fold.""" + +from .alphafold2 import AlphaFold2Model +from .base import FoldModel +from .esmfold import ESMFoldModel +from .future import FoldResultFuture diff --git a/openprotein/app/models/fold/alphafold2.py b/openprotein/app/models/fold/alphafold2.py new file mode 100644 index 0000000..5907bc0 --- /dev/null +++ b/openprotein/app/models/fold/alphafold2.py @@ -0,0 +1,54 @@ +from openprotein.api import fold + +from ..align import MSAFuture +from .base import FoldModel +from .future import FoldResultFuture + + +class AlphaFold2Model(FoldModel): + + model_id = "alphafold2" + + def __init__(self, session, model_id, metadata=None): + super().__init__(session, model_id, metadata) + self.id = self.model_id + + def fold( + self, + msa: str | MSAFuture, + num_recycles: int | None = None, + num_models: int = 1, + num_relax: int = 0, + ) -> FoldResultFuture: + """ + Post sequences to alphafold model. + + Parameters + ---------- + msa : Union[str, MSAFuture] + msa + num_recycles : int + number of times to recycle models + num_models : int + number of models to train - best model will be used + max_msa : Union[str, int] + maximum number of sequences in the msa to use. + relax_max_iterations : int + maximum number of iterations + + Returns + ------- + job : Job + """ + msa_id = msa.id if isinstance(msa, MSAFuture) else msa + + return FoldResultFuture.create( + session=self.session, + job=fold.fold_models_alphafold2_post( + self.session, + msa_id=msa_id, + num_recycles=num_recycles, + num_models=num_models, + num_relax=num_relax, + ), + ) diff --git a/openprotein/app/models/fold/base.py b/openprotein/app/models/fold/base.py new file mode 100644 index 0000000..3da72d5 --- /dev/null +++ b/openprotein/app/models/fold/base.py @@ -0,0 +1,81 @@ +from abc import ABC, abstractmethod + +from openprotein.api import fold +from openprotein.base import APISession +from openprotein.schemas import ModelMetadata + + +class FoldModel(ABC): + # overridden by subclasses + # get correct fold model + model_id: list[str] | str = "protfold" + + def __init__( + self, session: APISession, model_id: str, metadata: ModelMetadata | None = None + ): + self.session = session + self.id = model_id + self._metadata = metadata + + @classmethod + def get_model(cls): + if isinstance(cls.model_id, str): + return [cls.model_id] + return cls.model_id + + @staticmethod + def create( + session: APISession, + model_id: str, + metadata: ModelMetadata | None = None, + default: type["FoldModel"] | None = None, + ): + """ + Create and return an instance of the appropriate Future class based on the job type. + + Returns: + - An instance of the appropriate Future class. + """ + # Dynamically discover all subclasses of FutureBase + model_classes = FoldModel.__subclasses__() + + # Find the FoldModel class that matches the job type + for model_class in model_classes: + if model_id in model_class.get_model(): + return model_class( + session=session, model_id=model_id, metadata=metadata + ) + # default to FoldModel + if default is not None: + try: + return default(session=session, model_id=model_id, metadata=metadata) + except: + pass + raise ValueError(f"Unsupported model_id type: {model_id}") + + def __str__(self) -> str: + return self.id + + def __repr__(self) -> str: + return self.id + + @property + def metadata(self): + return self.get_metadata() + + def get_metadata(self) -> ModelMetadata: + """ + Get model metadata for this model. + + Returns + ------- + ModelMetadata + """ + if self._metadata is not None: + return self._metadata + self._metadata = fold.fold_model_get(self.session, self.id) + return self._metadata + + @abstractmethod + def fold(self, sequence: str, **kwargs): + pass diff --git a/openprotein/app/models/fold/esmfold.py b/openprotein/app/models/fold/esmfold.py new file mode 100644 index 0000000..eef1803 --- /dev/null +++ b/openprotein/app/models/fold/esmfold.py @@ -0,0 +1,34 @@ +from openprotein.api import fold + +from .base import FoldModel +from .future import FoldResultFuture + + +class ESMFoldModel(FoldModel): + + model_id = "esmfold" + + def __init__(self, session, model_id, metadata=None): + super().__init__(session, model_id, metadata) + self.id = self.model_id + + def fold(self, sequences: list[bytes], num_recycles: int = 1) -> FoldResultFuture: + """ + Fold sequences using this model. + + Parameters + ---------- + sequences : List[bytes] + sequences to fold + num_recycles : int + number of times to recycle models + Returns + ------- + FoldResultFuture + """ + return FoldResultFuture.create( + session=self.session, + job=fold.fold_models_esmfold_post( + self.session, sequences, num_recycles=num_recycles + ), + ) diff --git a/openprotein/app/models/fold/future.py b/openprotein/app/models/fold/future.py new file mode 100644 index 0000000..1735be7 --- /dev/null +++ b/openprotein/app/models/fold/future.py @@ -0,0 +1,53 @@ +from openprotein import config +from openprotein.api import fold +from openprotein.base import APISession +from openprotein.schemas import FoldJob + +from ..futures import Future, MappedFuture + + +class FoldResultFuture(MappedFuture, Future): + """Future Job for manipulating results""" + + job: FoldJob + + def __init__( + self, + session: APISession, + job: FoldJob, + sequences: list[bytes] | None = None, + max_workers: int = config.MAX_CONCURRENT_WORKERS, + ): + super().__init__(session, job, max_workers) + if sequences is None: + sequences = fold.fold_get_sequences(self.session, job_id=job.job_id) + self._sequences = sequences + + @property + def sequences(self) -> list[bytes]: + if self._sequences is None: + self._sequences = fold.fold_get_sequences(self.session, self.job.job_id) + return self._sequences + + @property + def id(self): + return self.job.job_id + + def keys(self): + return self.sequences + + def get(self, verbose=False) -> list[tuple[str, str]]: + return super().get(verbose=verbose) + + def get_item(self, sequence: bytes) -> bytes: + """ + Get fold results for specified sequence. + + Args: + sequence (bytes): sequence to fetch results for + + Returns: + np.ndarray: fold + """ + data = fold.fold_get_sequence_result(self.session, self.job.job_id, sequence) + return data diff --git a/openprotein/app/models/futures.py b/openprotein/app/models/futures.py new file mode 100644 index 0000000..835254d --- /dev/null +++ b/openprotein/app/models/futures.py @@ -0,0 +1,460 @@ +"""Application futures for waiting for results from jobs.""" + +import concurrent.futures +import logging +import time +from abc import ABC, abstractmethod +from datetime import datetime +from types import UnionType +from typing import Collection, Generator + +import tqdm +from openprotein import config +from openprotein.api import job as job_api +from openprotein.base import APISession +from openprotein.errors import TimeoutException +from openprotein.schemas import Job, JobStatus, JobType +from requests import Response +from typing_extensions import Self + +logger = logging.getLogger(__name__) + + +class Future(ABC): + """ + Base class for all Futures returning results from a job. + + This base class should be directly inherited for class discovery for factory create. + """ + + session: APISession + job: Job + + def __init__(self, session: APISession, job: Job): + self.session = session + self.job = job + + @classmethod + def create( + cls: type[Self], + session: APISession, + job_id: str | None = None, + job: Job | None = None, + response: Response | dict | None = None, + **kwargs, + ) -> Self: + """ + Create and return an instance of the appropriate Future class based on the job type. + + Parameters: + - session: Session for API interactions. + - job_id: The optional job_id of the Job to initialize this future with. + - job: The optional Job to initialize this future with. + - response: The optional response from a job request returning a job-like object. + - **kwargs: Additional keyword arguments to pass to the Future class constructor. + + Returns: + - An instance of the appropriate Future class. + """ + + # parse job + # default to use job_id first + if job_id is not None: + # get job + job = job_api.job_get(session=session, job_id=job_id) + # set obj to parse using job or response + obj = job or response + if obj is None: + raise ValueError("Expected job_id, job or response") + + # parse specific job + job = Job.create(obj, **kwargs) + + # Dynamically discover all subclasses of FutureBase + future_classes = Future.__subclasses__() + + # Find the Future class that matches the job + for future_class in future_classes: + if ( + type(job) == (future_type := future_class.__annotations__.get("job")) + or isinstance(future_type, UnionType) + and type(job) in future_type.__args__ + ): + future = future_class(session=session, job=job, **kwargs) + return future # type: ignore - needed since type checker doesnt know subclass + + raise ValueError(f"Unsupported job type: {job.job_type}") + + def __str__(self) -> str: + return str(self.job) + + def __repr__(self): + return repr(self.job) + + @property + def id(self) -> str: + return self.job.job_id + + job_id = id + + @property + def job_type(self) -> JobType: + return self.job.job_type + + @property + def status(self) -> JobStatus: + return self.job.status + + @property + def created_date(self) -> datetime: + return self.job.created_date + + @property + def start_date(self) -> datetime | None: + return self.job.start_date + + @property + def end_date(self) -> datetime | None: + return self.job.end_date + + @property + def progress_counter(self) -> int: + return self.job.progress_counter or 0 + + def done(self) -> bool: + """Check if job is complete""" + return self.status.done() + + def cancelled(self) -> bool: + """check if job is cancelled""" + return self.status.cancelled() + + def _update_progress(self, job: Job) -> int: + """update rules for jobs without counters""" + progress = job.progress_counter + # if progress is not None: # Check None before comparison + if progress is None: + if job.status == JobStatus.PENDING: + progress = 5 + if job.status == JobStatus.RUNNING: + progress = 25 + if job.status in [JobStatus.SUCCESS, JobStatus.FAILURE]: + progress = 100 + return progress or 0 # never None + + def _refresh_job(self) -> Job: + """Refresh and return internal specific job.""" + # dump extra kwargs to keep on refresh + kwargs = { + k: v for k, v in self.job.model_dump().items() if k not in Job.model_fields + } + job = Job.create( + job_api.job_get(session=self.session, job_id=self.job_id), **kwargs + ) + return job + + def refresh(self): + """Refresh job status.""" + self.job = self._refresh_job() + + @abstractmethod + def get(self, verbose: bool = False): + raise NotImplementedError() + + def _wait_job( + self, + interval: int = config.POLLING_INTERVAL, + timeout: int | None = None, + verbose: bool = False, + ) -> Job: + """ + Wait for a job to finish, and then get the results. + + Args: + session (APISession): Auth'd APIsession + interval (int): Wait between polls (secs). Defaults to POLLING_INTERVAL + timeout (int): Max. time to wait before raising error. Defaults to unlimited. + verbose (bool, optional): print status updates. Defaults to False. + + Raises: + TimeoutException: _description_ + + Returns: + _type_: _description_ + """ + start_time = time.time() + + def is_done(job: Job): + if timeout is not None: + elapsed_time = time.time() - start_time + if elapsed_time >= timeout: + raise TimeoutException( + f"Wait time exceeded timeout {timeout}, waited {elapsed_time}" + ) + return job.status.done() + + pbar = None + if verbose: + pbar = tqdm.tqdm(total=100, desc="Waiting", position=0) + + job = self._refresh_job() + while not is_done(job): + if pbar is not None: + # pbar.update(1) + # pbar.set_postfix({"status": job.status}) + progress = self._update_progress(job) + pbar.n = progress + pbar.set_postfix({"status": job.status}) + # pbar.refresh() + # print(f'Retry {retries}, status={self.job.status}, time elapsed {time.time() - start_time:.2f}') + time.sleep(interval) + job = self._refresh_job() + + if pbar is not None: + # pbar.update(1) + # pbar.set_postfix({"status": job.status}) + + progress = self._update_progress(job) + pbar.n = progress + pbar.set_postfix({"status": job.status}) + # pbar.refresh() + + return job + + def wait_until_done( + self, interval: int = config.POLLING_INTERVAL, timeout=None, verbose=False + ): + """ + Wait for job to complete. Do not fetch results (unlike wait()) + + Args: + interval (int, optional): time between polling. Defaults to config.POLLING_INTERVAL. + timeout (int, optional): max time to wait. Defaults to None. + verbose (bool, optional): verbosity flag. Defaults to False. + + Returns: + results: results of job + """ + job = self._wait_job(interval=interval, timeout=timeout, verbose=verbose) + self.job = job + return self.done() + + def wait( + self, + interval: int = config.POLLING_INTERVAL, + timeout: int | None = None, + verbose: bool = False, + ): + """ + Wait for job to complete, then fetch results. + + Args: + interval (int, optional): time between polling. Defaults to config.POLLING_INTERVAL. + timeout (int, optional): max time to wait. Defaults to None. + verbose (bool, optional): verbosity flag. Defaults to False. + + Returns: + results: results of job + """ + time.sleep(1) # buffer for BE to register job + job = self._wait_job(interval=interval, timeout=timeout, verbose=verbose) + self.job = job + return self.get() + + +class StreamingFuture(ABC): + @abstractmethod + def stream(self) -> Generator: + raise NotImplementedError() + + def get(self, verbose: bool = False) -> list: + generator = self.stream() + if verbose: + total = None + if hasattr(self, "__len__"): + total = len(self) # type: ignore - static type checker doesnt know + generator = tqdm.tqdm( + generator, desc="Retrieving", total=total, position=0, mininterval=1.0 + ) + return [entry for entry in generator] + + +class MappedFuture(StreamingFuture, ABC): + """Base future class for returning results from jobs with a mapping for keys (e.g. sequence) to results (e.g. embeddings).""" + + def __init__( + self, + session: APISession, + job: Job, + max_workers: int = config.MAX_CONCURRENT_WORKERS, + ): + """ + Retrieve results from asynchronous, mapped endpoints. + + Use `max_workers` > 0 to enable concurrent retrieval of multiple pages. + """ + self.session = session + self.job = job + self.max_workers = max_workers + self._cache = {} + + @abstractmethod + def keys(self): + raise NotImplementedError() + + @abstractmethod + def get_item(self, k): + raise NotImplementedError() + + def stream_sync(self): + """Stream the results back in-sync.""" + for k in self.keys(): + v = self[k] + yield k, v + + def stream_parallel(self): + """Stream the results back in parallel.""" + num_workers = self.max_workers + + def process(k): + v = self[k] + return k, v + + with concurrent.futures.ThreadPoolExecutor(max_workers=num_workers) as executor: + futures = [] + for k in self.keys(): + if k in self._cache: + yield k, self._cache[k] + else: + f = executor.submit(process, k) + futures.append(f) + + for f in concurrent.futures.as_completed(futures): + yield f.result() + + def stream(self): + """Stream results.""" + if self.max_workers > 0: + return self.stream_parallel() + return self.stream_sync() + + def __getitem__(self, k): + if k in self._cache: + return self._cache[k] + v = self.get_item(k) + self._cache[k] = v + return v + + def __len__(self): + return len(self.keys()) + + def __iter__(self): + return self.stream() + + +class PagedFuture(StreamingFuture, ABC): + """Base future class for returning results from jobs which have paged results.""" + + DEFAULT_PAGE_SIZE = 1024 + + def __init__( + self, + session: APISession, + job: Job, + page_size: int | None = None, + num_records: int | None = None, + max_workers: int = config.MAX_CONCURRENT_WORKERS, + ): + """ + Retrieve results from asynchronous, paged endpoints. + + Use `max_workers` > 0 to enable concurrent retrieval of multiple pages. + """ + if page_size is None: + page_size = self.DEFAULT_PAGE_SIZE + self.session = session + self.job = job + self.page_size = page_size + self.max_workers = max_workers + self._num_records = num_records + + @abstractmethod + def get_slice(self, start: int, end: int, **kwargs) -> Collection: + raise NotImplementedError() + + def stream_sync(self): + step = self.page_size + num_returned = step + offset = 0 + while num_returned >= step: + result_page = self.get_slice(start=offset, end=offset + step) + for result in result_page: + yield result + num_returned = len(result_page) + offset += num_returned + + # TODO - check the number of results, or store it somehow, so that we don't need + # to check the number of returned entries to see if we're finished (very awkward when using concurrency) + def stream_parallel(self): + step = self.page_size + offset = 0 + + num_workers = self.max_workers + + with concurrent.futures.ThreadPoolExecutor(max_workers=num_workers) as executor: + # submit the paged requests + futures: dict[concurrent.futures.Future, int] = {} + index: int = 0 + for _ in range(num_workers * 2): + f = executor.submit(self.get_slice, offset, offset + step) + futures[f] = index + index += 1 + offset += step + + # until we've retrieved all pages (known by retrieving a page with less than the requested number of records) + done = False + while not done: + results: list[list | None] = [None] * len(futures) + futures_next: dict[concurrent.futures.Future, int] = {} + index_next: int = 0 + next_result_index = 0 + # iterate the futures and submit new requests as needed + for f in concurrent.futures.as_completed(futures): + index = futures[f] + result_page = f.result() + results[index] = result_page + # check if we're done, meaning the result page is not full + done = done or len(result_page) < step + # if we aren't done, submit another request + if not done: + f = executor.submit(self.get_slice, offset, offset + step) + futures_next[f] = index_next + index_next += 1 + offset += step + # yield the results from this page + while ( + next_result_index < len(results) + and results[next_result_index] is not None + ): + result_page = results[next_result_index] + assert result_page is not None # checked above + for result in result_page: + yield result + next_result_index += 1 + # update the list of futures and wait on them again + futures = futures_next + + def stream(self): + if self.max_workers > 0: + return self.stream_parallel() + return self.stream_sync() + + +class InvalidFutureError(Exception): + """Error thrown if unexpected future is created from job.""" + + def __init__(self, future: Future, expected: type[Future]): + self.future = future + self.expected = future + self.message = f"Expected future of type {expected}, got {type(future)}" + super().__init__(self.message) diff --git a/openprotein/app/models/predict.py b/openprotein/app/models/predict.py new file mode 100644 index 0000000..e4411e7 --- /dev/null +++ b/openprotein/app/models/predict.py @@ -0,0 +1,183 @@ +import logging + +from openprotein.api import predict +from openprotein.base import APISession +from openprotein.schemas import ( + JobType, + WorkflowPredictJob, + WorkflowPredictSingleSiteJob, +) + +from .futures import Future, PagedFuture + +logger = logging.getLogger(__name__) + + +class PredictFuture(PagedFuture, Future): + """Future Job for manipulating results""" + + job: WorkflowPredictJob | WorkflowPredictSingleSiteJob + + def __init__( + self, + session: APISession, + job: WorkflowPredictJob | WorkflowPredictSingleSiteJob, + page_size: int = 1000, + ): + super().__init__(session=session, job=job, page_size=page_size) + + def __str__(self) -> str: + return str(self.job) + + def __repr__(self) -> str: + return repr(self.job) + + @property + def id(self): + return self.job.job_id + + def _fmt_results(self, results: list[WorkflowPredictJob.SequencePrediction]): + dict_results = {} + if len(results) > 0: + properties = set( + list(i["properties"].keys())[0] + for i in results[0].model_dump()["predictions"] + ) + for p in properties: + dict_results[p] = {} + for r in results: + s = r.sequence + props = [ + i.properties[p] for i in r.predictions if p in i.properties + ][0] + dict_results[p][s] = { + "mean": props["y_mu"], + "variance": props["y_var"], + } + return dict_results + + def _fmt_ssp_results( + self, results: list[WorkflowPredictSingleSiteJob.MutantPrediction] + ): + dict_results = {} + if len(results) > 0: + properties = set( + list(i["properties"].keys())[0] + for i in results[0].model_dump()["predictions"] + ) + for p in properties: + dict_results[p] = {} + for r in results: + s = s = f"{r.position+1}{r.amino_acid}" + props = [ + i.properties[p] for i in r.predictions if p in i.properties + ][0] + dict_results[p][s] = { + "mean": props["y_mu"], + "variance": props["y_var"], + } + return dict_results + + # def get(self, verbose: bool = False) -> dict: + # """ + # Get all the results of the predict job. + + # Args: + # verbose (bool, optional): If True, print verbose output. Defaults False. + + # Raises: + # APIError: If there is an issue with the API request. + + # Returns: + # PredictJob: A list of predict objects representing the results. + # """ + # step = self.page_size + + # results = [] + # num_returned = step + # offset = 0 + + # while num_returned >= step: + # try: + # response = self.get_results(page_offset=offset, page_size=step) + # assert isinstance(response.result, list) + # results += response.result + # num_returned = len(response.result) + # offset += num_returned + # except APIError as exc: + # if verbose: + # print(f"Failed to get results: {exc}") + + # if self.job.job_type == JobType.workflow_predict: + # return self._fmt_results(results) + # else: + # return self._fmt_ssp_results(results) + + def get_dict(self, verbose: bool = False) -> dict: + + results: list = [] + num_returned = self.page_size + offset = 0 + + while num_returned >= self.page_size: + try: + predict_job_results = self.get_results( + page_offset=offset, page_size=self.page_size + ) + if predict_job_results.result is not None: + results += predict_job_results.result + num_returned = len(predict_job_results.result) + offset += num_returned + except Exception as exc: + if verbose: + logging.error(f"Failed to get results: {exc}") + + if self.job.job_type == JobType.workflow_predict_single_site: + return self._fmt_ssp_results(results) + else: + return self._fmt_results(results) + + def get_slice(self, start: int, end: int): + results = self.get_results(page_size=end - start, page_offset=start) + return results.result or [] # could be none + + def get_results( + self, page_size: int | None = None, page_offset: int | None = None + ) -> WorkflowPredictSingleSiteJob | WorkflowPredictJob: + """ + Retrieves results from a Predict job. + + it uses the appropriate method to retrieve the results based on job_type. + + Parameters + ---------- + page_size : Optional[int], default is None + The number of results to be returned per page. If None, all results are returned. + page_offset : Optional[int], default is None + The number of results to skip. If None, defaults to 0. + + Returns + ------- + Union[PredictSingleSiteJob, PredictJob] + The job object representing the Predict job. The exact type of job depends on the job type. + + Raises + ------ + HTTPError + If the GET request does not succeed. + """ + assert self.id is not None + if self.job.job_type is JobType.workflow_predict_single_site: + return predict.get_single_site_prediction_results( + session=self.session, + job_id=self.id, + page_size=page_size, + page_offset=page_offset, + ) + else: + return predict.get_prediction_results( + session=self.session, + job_id=self.id, + page_size=page_size, + page_offset=page_offset, + ) diff --git a/openprotein/app/models/predictor/__init__.py b/openprotein/app/models/predictor/__init__.py new file mode 100644 index 0000000..83f7519 --- /dev/null +++ b/openprotein/app/models/predictor/__init__.py @@ -0,0 +1,4 @@ +"""OpenProtein app-level models for predictor.""" + +from .predict import PredictionResultFuture +from .predictor import PredictorModel diff --git a/openprotein/app/models/predictor/predict.py b/openprotein/app/models/predictor/predict.py new file mode 100644 index 0000000..18a0b8c --- /dev/null +++ b/openprotein/app/models/predictor/predict.py @@ -0,0 +1,78 @@ +import numpy as np +from openprotein.api import predictor +from openprotein.base import APISession +from openprotein.schemas import ( + PredictJob, + PredictMultiJob, + PredictMultiSingleSiteJob, + PredictSingleSiteJob, +) + +from ..futures import Future + + +class PredictionResultFuture(Future): + """Future Job for manipulating results""" + + job: PredictJob | PredictSingleSiteJob | PredictMultiJob | PredictMultiSingleSiteJob + + def __init__( + self, + session: APISession, + job: ( + PredictJob + | PredictSingleSiteJob + | PredictMultiJob + | PredictMultiSingleSiteJob + ), + sequences: list[bytes] | None = None, + ): + super().__init__(session, job) + self._sequences = sequences + + @property + def sequences(self): + if self._sequences is None: + self._sequences = predictor.predictor_predict_get_sequences( + self.session, self.job.job_id + ) + return self._sequences + + @property + def id(self): + return self.job.job_id + + def keys(self): + return self.sequences + + def get_item(self, sequence: bytes) -> tuple[np.ndarray, np.ndarray]: + """ + Get embedding results for specified sequence. + + Args: + sequence (bytes): sequence to fetch results for + + Returns: + mu (np.ndarray): means of sequence prediction + var (np.ndarray): variances of sequence prediction + """ + data = predictor.predictor_predict_get_sequence_result( + self.session, self.job.job_id, sequence + ) + return predictor.decode_predict(data) + + def get(self, verbose: bool = False) -> tuple[np.ndarray, np.ndarray]: + """ + Get embedding results for specified sequence. + + Args: + sequence (bytes): sequence to fetch results for + + Returns: + mu (np.ndarray): means of predictions + var (np.ndarray): variances of predictions + """ + data = predictor.predictor_predict_get_batched_result( + self.session, self.job.job_id + ) + return predictor.decode_predict(data, batched=True) diff --git a/openprotein/app/models/predictor/predictor.py b/openprotein/app/models/predictor/predictor.py new file mode 100644 index 0000000..e919e94 --- /dev/null +++ b/openprotein/app/models/predictor/predictor.py @@ -0,0 +1,196 @@ +import numpy as np +from openprotein.api import assaydata +from openprotein.api import job as job_api +from openprotein.api import predictor, svd +from openprotein.base import APISession +from openprotein.errors import InvalidParameterError +from openprotein.schemas import CVJob, PredictorMetadata, TrainJob + +from ..assaydata import AssayDataset +from ..embeddings import EmbeddingModel +from ..futures import Future +from ..svd import SVDModel +from .predict import PredictionResultFuture + + +class CVResultFuture(Future): + """Future Job for manipulating results""" + + job: CVJob + + def __init__( + self, + session: APISession, + job: CVJob, + ): + super().__init__(session, job) + + @property + def id(self): + return self.job.job_id + + def get(self, verbose: bool = False) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Get embedding results for specified sequence. + + Args: + sequence (bytes): sequence to fetch results for + + Returns: + mu (np.ndarray): means of predictions + var (np.ndarray): variances of predictions + """ + data = predictor.predictor_crossvalidate_get(self.session, self.job.job_id) + return predictor.decode_crossvalidate(data) + + +class PredictorModel(Future): + """ + Class providing predict endpoint for fitted predictor models. + + Also implements a Future that waits for train job. + """ + + job: TrainJob + + def __init__( + self, + session: APISession, + job: TrainJob | None = None, + metadata: PredictorMetadata | None = None, + ): + """Initializes with either job get or svd metadata get.""" + self._training_assay = None + # initialize the metadata + if metadata is None: + if job is None: + raise ValueError("Expected predictor metadata or job") + metadata = predictor.predictor_get(session, job.job_id) + self._metadata = metadata + if job is None: + job = TrainJob.create(job_api.job_get(session=session, job_id=metadata.id)) + # getter initializes job if not provided + super().__init__(session, job) + + def __str__(self) -> str: + return str(self.metadata) + + def __repr__(self) -> str: + return repr(self.metadata) + + @property + def id(self): + return self._metadata.id + + @property + def reduction(self): + return self._metadata.model_spec.features.reduction + + @property + def sequence_length(self): + if (constraints := self._metadata.model_spec.constraints) is not None: + return constraints.sequence_length + return None + + @property + def training_assay(self) -> AssayDataset: + if self._training_assay is None: + self._training_assay = self.get_assay() + return self._training_assay + + @property + def training_properties(self) -> list[str]: + return self._metadata.training_dataset.properties + + @property + def metadata(self): + self._refresh_metadata() + return self._metadata + + def _refresh_metadata(self): + if not self._metadata.is_done(): + self._metadata = predictor.predictor_get(self.session, self._metadata.id) + + def get_model(self) -> EmbeddingModel | SVDModel | None: + """Fetch embeddings model""" + if (features := self._metadata.model_spec.features) and ( + model_id := features.model_id + ) is None: + return None + elif features.type.upper() == "PLM": + model = EmbeddingModel.create(session=self.session, model_id=model_id) + elif features.type.upper() == "SVD": + model = SVDModel( + session=self.session, + metadata=svd.svd_get(session=self.session, svd_id=model_id), + ) + else: + raise ValueError(f"Unexpected feature type {features.type}") + return model + + @property + def model(self) -> EmbeddingModel | SVDModel | None: + return self.get_model() + + def delete(self) -> bool: + """ + Delete this SVD model. + """ + return predictor.predictor_delete(self.session, self.id) + + def get(self, verbose: bool = False): + # overload for Future + return self + + def get_assay(self) -> AssayDataset: + """ + Get assay used for train job. + + Returns + ------- + list[bytes]: list of sequences + """ + return AssayDataset( + session=self.session, + metadata=assaydata.get_assay_metadata( + self.session, self._metadata.training_dataset.assay_id + ), + ) + + def crossvalidate(self, n_splits: int | None = None) -> CVResultFuture: + return CVResultFuture.create( + session=self.session, + job=predictor.predictor_crossvalidate_post( + session=self.session, + predictor_id=self.id, + n_splits=n_splits, + ), + ) + + def predict(self, sequences: list[bytes] | list[str]) -> PredictionResultFuture: + if self.sequence_length is not None: + for sequence in sequences: + sequence = sequence if isinstance(sequence, str) else sequence.decode() + if len(sequence) != self.sequence_length: + raise InvalidParameterError( + f"Expected sequence to predict to be of length {self.sequence_length}" + ) + return PredictionResultFuture.create( + session=self.session, + job=predictor.predictor_predict_post( + session=self.session, predictor_id=self.id, sequences=sequences + ), + ) + + def single_site(self, sequence: bytes | str) -> PredictionResultFuture: + if self.sequence_length is not None: + if len(sequence) != self.sequence_length: + raise InvalidParameterError( + f"Expected sequence to predict to be of length {self.sequence_length}" + ) + return PredictionResultFuture.create( + session=self.session, + job=predictor.predictor_predict_single_site_post( + session=self.session, predictor_id=self.id, base_sequence=sequence + ), + ) diff --git a/openprotein/app/models/svd.py b/openprotein/app/models/svd.py new file mode 100644 index 0000000..70ce442 --- /dev/null +++ b/openprotein/app/models/svd.py @@ -0,0 +1,211 @@ +from typing import TYPE_CHECKING + +import numpy as np +from openprotein.api import assaydata +from openprotein.api import job as job_api +from openprotein.api import predictor, svd +from openprotein.base import APISession +from openprotein.errors import InvalidParameterError +from openprotein.schemas import FeatureType, FitJob, SVDEmbeddingsJob, SVDMetadata + +from .assaydata import AssayDataset, AssayMetadata +from .embeddings import EmbeddingModel, EmbeddingResultFuture +from .futures import Future + +if TYPE_CHECKING: + from .predictor import PredictorModel + + +class SVDModel(Future): + """ + Class providing embedding endpoint for SVD models. \ + Also allows retrieving embeddings of sequences used to fit the SVD with `get`. + Implements a Future to allow waiting for a fit job. + """ + + job: FitJob + + def __init__( + self, + session: APISession, + job: FitJob | None = None, + metadata: SVDMetadata | None = None, + ): + """Initializes with either job get or svd metadata get.""" + if metadata is None: + # use job to fetch metadata + if job is None: + raise ValueError("Expected svd metadata or job") + metadata = svd.svd_get(session, job.job_id) + self._metadata = metadata + if job is None: + job = FitJob.create(job_api.job_get(session=session, job_id=metadata.id)) + # getter initializes job if not provided + super().__init__(session, job) + + def __str__(self) -> str: + return str(self.metadata) + + def __repr__(self) -> str: + return repr(self.metadata) + + @property + def id(self): + return self._metadata.id + + @property + def n_components(self): + return self._metadata.n_components + + @property + def sequence_length(self): + return self._metadata.sequence_length + + @property + def reduction(self): + return self._metadata.reduction + + @property + def metadata(self): + self._refresh_metadata() + return self._metadata + + def _refresh_metadata(self): + if not self._metadata.is_done(): + self._metadata = svd.svd_get(self.session, self._metadata.id) + + def get_model(self) -> EmbeddingModel: + """Fetch embeddings model""" + model = EmbeddingModel.create(session=self.session, model_id=self._metadata.id) + return model + + @property + def model(self) -> EmbeddingModel: + return self.get_model() + + def delete(self) -> bool: + """ + Delete this SVD model. + """ + return svd.svd_delete(self.session, self.id) + + def get(self, verbose: bool = False): + # overload for AsyncJobFuture + return self + + def get_inputs(self) -> list[bytes]: + """ + Get sequences used for svd job. + + Returns + ------- + List[bytes]: list of sequences + """ + return svd.svd_get_sequences(session=self.session, svd_id=self.id) + + def embed( + self, sequences: list[bytes] | list[str], **kwargs + ) -> EmbeddingResultFuture: + """ + Use this SVD model to get reduced embeddings from input sequences. + + Parameters + ---------- + sequences : List[bytes] + List of protein sequences. + + Returns + ------- + EmbeddingResultFuture + Class for further job manipulation. + """ + return EmbeddingResultFuture.create( + session=self.session, + job=svd.svd_embed_post( + session=self.session, svd_id=self.id, sequences=sequences, **kwargs + ), + sequences=sequences, + ) + + def fit_gp( + self, + assay: AssayMetadata | AssayDataset | str, + properties: list[str], + name: str | None = None, + description: str | None = None, + **kwargs, + ) -> "PredictorModel": + """ + Fit a GP on assay using this embedding model and hyperparameters. + + Parameters + ---------- + assay : AssayMetadata | str + Assay to fit GP on. + properties: list[str] + Properties in the assay to fit the gp on. + + Returns + ------- + PredictorModel + """ + # local import to resolve cyclic + from .predictor import PredictorModel + + model_id = self.id + # get assay if str + assay = ( + assaydata.get_assay_metadata(session=self.session, assay_id=assay) + if isinstance(assay, str) + else assay + ) + # extract assay_id + assay_id = assay.assay_id if isinstance(assay, AssayMetadata) else assay.id + if ( + self.sequence_length is not None + and assay.sequence_length != self.sequence_length + ): + raise InvalidParameterError( + f"Expected dataset to be of sequence length {self.sequence_length} due to svd fitted constraints" + ) + if len(properties) == 0: + raise InvalidParameterError("Expected (at-least) 1 property to train") + if not set(properties) <= set(assay.measurement_names): + raise InvalidParameterError( + f"Expected all provided properties to be a subset of assay's measurements: {assay.measurement_names}" + ) + # TODO - support multitask + if len(properties) > 1: + raise InvalidParameterError( + "Training a multitask GP is not yet supported (i.e. number of properties should only be 1 for now)" + ) + job = predictor.predictor_fit_gp_post( + session=self.session, + assay_id=assay_id, + properties=properties, + feature_type=FeatureType.SVD, + model_id=model_id, + name=name, + description=description, + **kwargs, + ) + return PredictorModel.create(session=self.session, job=job) + + +class SVDEmbeddingResultFuture(EmbeddingResultFuture, Future): + """Future for manipulating results for embeddings-related requests.""" + + job: SVDEmbeddingsJob + + def get_item(self, sequence: bytes) -> np.ndarray: + """ + Get embedding results for specified sequence. + + Args: + sequence (bytes): sequence to fetch results for + + Returns: + np.ndarray: embeddings + """ + data = svd.embed_get_sequence_result(self.session, self.job.job_id, sequence) + return svd.embed_decode(data) diff --git a/openprotein/app/models/train.py b/openprotein/app/models/train.py new file mode 100644 index 0000000..fa5f867 --- /dev/null +++ b/openprotein/app/models/train.py @@ -0,0 +1,303 @@ +from openprotein.api import assaydata +from openprotein.api import job as job_api +from openprotein.api import train +from openprotein.base import APISession +from openprotein.errors import APIError +from openprotein.schemas import ( + WorkflowCVItem, + WorkflowCVJob, + WorkflowTrainJob, + WorkflowTrainStep, +) + +from .assaydata import AssayDataset +from .futures import Future, PagedFuture +from .predict import PredictFuture + + +class TrainFuture(Future): + """ + This class provides functionality for retrieving the + results of a training job and initiating cross-validation jobs. + + Attributes + ---------- + session : APISession + The session object to use for API communication. + job : Job + The Job object for this training job. + + Methods + ------- + get_results() -> TrainGraph: + Returns the results of the training job. + crossvalidate() -> CVFuture: + Submits a cross-validation job and returns the future. + """ + + job: WorkflowTrainJob + + def __init__( + self, + session: APISession, + job: WorkflowTrainJob, + traingraph: list[WorkflowTrainStep] | None = None, + assay: AssayDataset | None = None, + ): + # local import for cyclic dependency on app services + from ..services.predict import PredictService + + super().__init__(session, job) + self._predict = PredictService(session) + self._traingraph = traingraph + self._assay = assay + self._args = None + + @property + def id(self): + return self.job.job_id + + @property + def args(self) -> dict: + if self._args is None: + self._args = job_api.job_args_get(session=self.session, job_id=self.id) + return self._args + + @property + def assay(self): + if self._assay is None: + assay_id: str | None = self.args.get("assay_id") + if assay_id is None: + raise InvalidTrainArgs( + "'assay_id' not in train args. Something went wrong." + ) + self._assay = AssayDataset( + session=self.session, + metadata=assaydata.get_assay_metadata( + session=self.session, assay_id=assay_id + ), + ) + return self._assay + + def __str__(self) -> str: + return str(self.job) + + def __repr__(self) -> str: + return repr(self.job) + + def _fmt_results(self, results: list[WorkflowTrainStep] | None) -> dict: + train_dict = {} + if results is not None: + tags = set([i.tag for i in results]) + for tag in tags: + train_dict[tag] = [ + i.loss for i in results if i.model_dump()["tag"] == tag + ] + return train_dict + + def get(self, verbose: bool = False) -> dict: + """ + Gets the results of the training job. + + Returns + ------- + TrainGraph + The results of the training job. + """ + try: + if self._traingraph is None: + self._traingraph = train.get_training_results( + session=self.session, job_id=self.id + ).traingraph + results = self._traingraph + except APIError as exc: + if verbose: + print(f"Failed to get results: {exc}") + raise exc + return self._fmt_results(results) + + def crossvalidate(self): + """ + Submits a cross-validation job. + + If a cross-validation job has already been created, it returns that job. + Otherwise, it creates a new cross-validation job and returns it. + + Returns + ------- + CVFuture + The cross-validation job associated with this training job. + """ + cv_future = CVFuture.create( + session=self.session, + job=train.crossvalidate( + session=self.session, + train_job_id=self.id, + ), + train_job_id=self.id, + ) + self.crossvalidation = cv_future + return cv_future + + def list_models(self): + """ + List models assoicated with job + + Parameters + ---------- + session : APISession + Session object for API communication. + job_id : str + job ID + + Returns + ------- + List + List of models + """ + return train.list_models(self.session, self.job.job_id) + + def predict( + self, sequences: list[str], model_ids: list[str] | None = None + ) -> PredictFuture: + """ + Creates a predict job based on the training job. + + Parameters + ---------- + sequences : List[str] + The list of sequences to be used for the Predict job. + model_ids : List[str], optional + The list of model ids to be used for Predict. Default is None. + + Returns + ------- + PredictFuture + The job object representing the Predict job. + """ + return self._predict.create_predict_job( + sequences=sequences, train_job=self, model_ids=model_ids + ) + + def predict_single_site( + self, + sequence: str, + model_ids: list[str] | None = None, + ) -> PredictFuture: + """ + Creates a new Predict job for single site mutation analysis with a trained model. + + Parameters + ---------- + sequence : str + The sequence for single site analysis. + train_job : Any + The train job object representing the trained model. + model_ids : List[str], optional + The list of model ids to be used for Predict. Default is None. + + Returns + ------- + PredictFuture + The job object representing the Predict job. + + Creates a predict job based on the training job + """ + return self._predict.create_predict_single_site( + sequence=sequence, train_job=self, model_ids=model_ids + ) + + +class InvalidTrainArgs(Exception): ... + + +class CVFuture(PagedFuture, Future): + """ + This class helps initiating, submitting, and retrieving the + results of a cross-validation job. + + Attributes + ---------- + session : APISession + The session object to use for API communication. + train_job_id : str + The id of the training job associated with this cross-validation job. + job : Job + The Job object for this cross-validation job. + page_size : int + The number of items to retrieve in a single request. + + """ + + job: WorkflowCVJob + + def __init__( + self, + session: APISession, + job: WorkflowCVJob, + train_job_id: str | None = None, + page_size: int = 1000, + ): + """ + Constructs a new CVFuture instance. + + Parameters + ---------- + session : APISession + The session object to use for API communication. + train_job_id : str + The id of the training job associated with this cross-validation job. + job : Job, optional + The Job object for this cross-validation job. + """ + super().__init__(session=session, job=job, page_size=page_size) + if train_job_id is None: + assert ( + job.prerequisite_job_id is not None + ), "expected prerequisite train job id" + train_job_id = job.prerequisite_job_id + self.train_job_id = train_job_id + + def __str__(self) -> str: + return str(self.job) + + def __repr__(self) -> str: + return repr(self.job) + + @property + def id(self): + return self.job.job_id + + def _fmt_results(self, results: WorkflowCVJob) -> list[WorkflowCVItem]: + return results.result if results.result is not None else [] + + def get_slice(self, start: int, end: int): + results = self.get_crossvalidation(page_size=end - start, page_offset=start) + return self._fmt_results(results) + + def get_crossvalidation( + self, page_size: int | None = None, page_offset: int | None = None + ) -> WorkflowCVJob: + """ + Retrieves the results of the cross-validation job. + + + Parameters + ---------- + page_size : int, optional + The number of items to retrieve in a single request.. + page_offset : int, optional + The offset to start retrieving items from. Default is 0. + + Returns + ------- + dict + The results of the cross-validation job. + + """ + return train.get_crossvalidation( + session=self.session, + job_id=self.job.job_id, + page_size=page_size, + page_offset=page_offset, + ) diff --git a/openprotein/app/services/__init__.py b/openprotein/app/services/__init__.py new file mode 100644 index 0000000..87548af --- /dev/null +++ b/openprotein/app/services/__init__.py @@ -0,0 +1,11 @@ +"""Application services for OpenProtein.""" + +from .align import AlignAPI +from .assaydata import AssayDataAPI +from .design import DesignAPI +from .embeddings import EmbeddingsAPI +from .fold import FoldAPI +from .job import JobsAPI +from .predictor import PredictorAPI +from .svd import SVDAPI +from .train import TrainingAPI diff --git a/openprotein/app/services/align.py b/openprotein/app/services/align.py new file mode 100644 index 0000000..2354dca --- /dev/null +++ b/openprotein/app/services/align.py @@ -0,0 +1,147 @@ +from typing import BinaryIO, Iterator + +from openprotein.api import align +from openprotein.app.models import MSAFuture, PromptFuture +from openprotein.base import APISession +from openprotein.schemas import Job, PoetInputType + + +class AlignAPI: + """API interface for calling Poet and Align endpoints""" + + def __init__(self, session: APISession): + self.session = session + + def upload_msa(self, msa_file) -> MSAFuture: + """ + Upload an MSA from file. + + Parameters + ---------- + msa_file : str, optional + Ready-made MSA. If not provided, default value is None. + + Raises + ------ + APIError + If there is an issue with the API request. + + Returns + ------- + MSAFuture + Future object awaiting the contents of the MSA upload. + """ + return MSAFuture.create( + session=self.session, job=align.msa_post(self.session, msa_file=msa_file) + ) + + def create_msa(self, seed: bytes) -> MSAFuture: + """ + Construct an MSA via homology search with the seed sequence. + + Parameters + ---------- + seed : bytes + Seed sequence for the MSA construction. + + Raises + ------ + APIError + If there is an issue with the API request. + + Returns + ------- + MSAJob + Job object containing the details of the MSA construction. + """ + return MSAFuture.create( + session=self.session, job=align.msa_post(self.session, seed=seed) + ) + + def upload_prompt(self, prompt_file: BinaryIO) -> PromptFuture: + """ + Directly upload a prompt. + + Bypass post_msa and prompt_post steps entirely. In this case PoET will use the prompt as is. + You can specify multiple prompts (one per replicate) with an and newline between CSVs. + + Parameters + ---------- + prompt_file : BinaryIO + Binary I/O object representing the prompt file. + + Raises + ------ + APIError + If there is an issue with the API request. + + Returns + ------- + PromptJob + An object representing the status and results of the prompt job. + """ + return PromptFuture.create( + session=self.session, + job=align.upload_prompt_post(session=self.session, prompt_file=prompt_file), + ) + + def get_prompt( + self, job: Job, prompt_index: int | None = None + ) -> Iterator[list[str]]: + """ + Get prompts for a given job. + + Parameters + ---------- + job : Job + The job for which to retrieve data. + prompt_index : Optional[int] + The replicate number for the prompt (input_type=-PROMPT only) + + Returns + ------- + csv.reader + A CSV reader for the response data. + """ + return align.get_input( + session=self.session, + job=job, + input_type=PoetInputType.PROMPT, + prompt_index=prompt_index, + ) + + def get_seed(self, job: Job) -> Iterator[list[str]]: + """ + Get input data for a given msa job. + + Parameters + ---------- + job : Job + The job for which to retrieve data. + + Returns + ------- + csv.reader + A CSV reader for the response data. + """ + return align.get_input( + session=self.session, job=job, input_type=PoetInputType.INPUT + ) + + def get_msa(self, job: Job) -> Iterator[list[str]]: + """ + Get generated MSA for a given job. + + Parameters + ---------- + job : Job + The job for which to retrieve data. + + Returns + ------- + csv.reader + A CSV reader for the response data. + """ + return align.get_input( + session=self.session, job=job, input_type=PoetInputType.MSA + ) diff --git a/openprotein/app/services/assaydata.py b/openprotein/app/services/assaydata.py new file mode 100644 index 0000000..5b46ec7 --- /dev/null +++ b/openprotein/app/services/assaydata.py @@ -0,0 +1,127 @@ +import io + +import pandas as pd +from openprotein.api import assaydata +from openprotein.app.models import AssayDataset, AssayMetadata +from openprotein.base import APISession + + +class AssayDataAPI: + """API interface for calling AssayData endpoints""" + + def __init__(self, session: APISession): + """ + init the DataAPI. + + Parameters + ---------- + session : APISession + Session object for API communication. + """ + self.session = session + + def list(self) -> list[AssayDataset]: + """ + List all assay datasets. + + Returns + ------- + List[AssayDataset] + List of all assay datasets. + """ + metadata = assaydata.assaydata_list(self.session) + return [AssayDataset(self.session, x) for x in metadata] + + def create( + self, table: pd.DataFrame, name: str, description: str | None = None + ) -> AssayDataset: + """ + Create a new assay dataset. + + Parameters + ---------- + table : pd.DataFrame + DataFrame containing the assay data. + name : str + Name of the assay dataset. + description : str, optional + Description of the assay dataset, by default None. + + Returns + ------- + AssayDataset + Created assay dataset. + """ + stream = io.BytesIO() + table.to_csv(stream, index=False) + stream.seek(0) + metadata = assaydata.assaydata_post( + self.session, stream, name, assay_description=description + ) + metadata.sequence_length = len(table["sequence"].values[0]) + return AssayDataset(self.session, metadata) + + def get(self, assay_id: str, verbose: bool = False) -> AssayDataset: + """ + Get an assay dataset by its ID. + + Parameters + ---------- + assay_id : str + ID of the assay dataset. + + Returns + ------- + AssayDataset + Assay dataset with the specified ID. + + Raises + ------ + KeyError + If no assay dataset with the given ID is found. + """ + return AssayDataset( + self.session, assaydata.get_assay_metadata(self.session, assay_id) + ) + + def load_assay(self, assay_id: str) -> AssayDataset: + """ + Reload a Submitted job to resume from where you left off! + + + Parameters + ---------- + assay_id : str + The identifier of the job whose details are to be loaded. + + Returns + ------- + Job + Job + + Raises + ------ + HTTPError + If the request to the server fails. + InvalidJob + If the Job is of the wrong type + + """ + metadata = self.get(assay_id) + # if job_details.job_type != JobType.train: + # raise InvalidJob(f"Job {job_id} is not of type {JobType.train}") + return AssayDataset( + self.session, + metadata, + ) + + def __len__(self) -> int: + """ + Get the number of assay datasets. + + Returns + ------- + int + Number of assay datasets. + """ + return len(self.list()) diff --git a/openprotein/app/services/design.py b/openprotein/app/services/design.py new file mode 100644 index 0000000..da3a35f --- /dev/null +++ b/openprotein/app/services/design.py @@ -0,0 +1,77 @@ +from openprotein.api import design +from openprotein.app.models import DesignFuture +from openprotein.base import APISession +from openprotein.schemas import DesignJobCreate, DesignResults + + +class DesignAPI: + """interface for calling Design endpoints""" + + def __init__(self, session: APISession): + self.session = session + + def create_design_job(self, design_job: DesignJobCreate) -> DesignFuture: + """ + Start a protein design job based on your assaydata, a trained ML model and Criteria (specified here). + + Parameters + ---------- + design_job : DesignJobCreate + The details of the design job to be created, with the following parameters: + - assay_id: The ID for the assay. + - criteria: A list of CriterionItem lists for evaluating the design. + - num_steps: The number of steps in the genetic algo. Default is 8. + - pop_size: The population size for the genetic algo. Default is None. + - n_offsprings: The number of offspring for the genetic algo. Default is None. + - crossover_prob: The crossover probability for the genetic algo. Default is None. + - crossover_prob_pointwise: The pointwise crossover probability for the genetic algo. Default is None. + - mutation_average_mutations_per_seq: The average number of mutations per sequence. Default is None. + - allowed_tokens: A dict of positions and allows tokens (e.g. *{1:['G','L']})* ) designating how mutations may occur. Default is None. + + Returns + ------- + DesignFuture + The created job as a DesignFuture instance. + """ + return DesignFuture.create( + session=self.session, job=design.create_design_job(self.session, design_job) + ) + + def get_design_results( + self, + job_id: str, + step: int | None = None, + page_size: int | None = None, + page_offset: int | None = None, + ) -> DesignResults: + """ + Retrieves the results of a Design job. + + Parameters + ---------- + job_id : str + The ID for the design job + step: int + The design step to retrieve, if None: retrieve all. + page_size : Optional[int], default is None + The number of results to be returned per page. If None, all results are returned. + page_offset : Optional[int], default is None + The number of results to skip. If None, defaults to 0. + + Returns + ------- + DesignJob + The job object representing the Design job. + + Raises + ------ + HTTPError + If the GET request does not succeed. + """ + return design.get_design_results( + self.session, + step=step, + job_id=job_id, + page_size=page_size, + page_offset=page_offset, + ) diff --git a/openprotein/app/services/embeddings.py b/openprotein/app/services/embeddings.py new file mode 100644 index 0000000..fe69c0d --- /dev/null +++ b/openprotein/app/services/embeddings.py @@ -0,0 +1,126 @@ +from openprotein.base import APISession +from openprotein.app.models.embeddings import EmbeddingModel, OpenProteinModel, ESMModel, PoETModel, EmbeddingResultFuture +from openprotein.api import embedding + +class EmbeddingsAPI: + """ + This class defines a high level interface for accessing the embeddings API. + + You can access all our models either via :meth:`get_model` or directly through the session's embedding attribute using the model's ID and the desired method. For example, to use the attention method on the protein sequence model, you would use ``session.embedding.prot_seq.attn()``. + + Examples + -------- + Accessing a model's method: + + .. code-block:: python + + # To call the attention method on the protein sequence model: + import openprotein + session = openprotein.connect(username="user", password="password") + session.embedding.prot_seq.attn() + + Using the `get_model` method: + + .. code-block:: python + + # Get a model instance by name: + import openprotein + session = openprotein.connect(username="user", password="password") + # list available models: + print(session.embedding.list_models() ) + # init model by name + model = session.embedding.get_model('prot-seq') + """ + + # added for static typing, eg pylance, for autocomplete + # at init these are all overwritten. + prot_seq: OpenProteinModel + rotaprot_large_uniref50w: OpenProteinModel + rotaprot_large_uniref90_ft: OpenProteinModel + poet: PoETModel + + esm1b: ESMModel # alias + esm1b_t33_650M_UR50S: ESMModel + + esm1v: ESMModel # alias + esm1v_t33_650M_UR90S_1: ESMModel + esm1v_t33_650M_UR90S_2: ESMModel + esm1v_t33_650M_UR90S_3: ESMModel + esm1v_t33_650M_UR90S_4: ESMModel + esm1v_t33_650M_UR90S_5: ESMModel + + esm2: ESMModel # alias + esm2_t12_35M_UR50D: ESMModel + esm2_t30_150M_UR50D: ESMModel + esm2_t33_650M_UR50D: ESMModel + esm2_t36_3B_UR50D: ESMModel + esm2_t6_8M_UR50D: ESMModel + + def __init__(self, session: APISession): + self.session = session + # dynamically add models from api list + self._load_models() + + def _load_models(self): + # Dynamically add model instances as attributes - precludes any drift + models = self.list_models() + for model in models: + model_name = model.id.replace("-", "_") # hyphens out + setattr(self, model_name, model) + # Setup aliases + self.esm1b = self.esm1b_t33_650M_UR50S + self.esm1v = self.esm1v_t33_650M_UR90S_1 + self.esm2 = self.esm2_t33_650M_UR50D + + def list_models(self) -> list[EmbeddingModel]: + """list models available for creating embeddings of your sequences""" + models = [] + for model_id in embedding.list_models(self.session): + models.append( + EmbeddingModel.create( + session=self.session, model_id=model_id, default=EmbeddingModel + ) + ) + return models + + def get_model(self, name: str) -> EmbeddingModel: + """ + Get model by model_id. + + ProtembedModel allows all the usual job manipulation: \ + e.g. making POST and GET requests for this model specifically. + + + Parameters + ---------- + model_id : str + the model identifier + + Returns + ------- + ProtembedModel + The model + + Raises + ------ + HTTPError + If the GET request does not succeed. + """ + model_name = name.replace("-", "_") + return getattr(self, model_name) + + def __get_results(self, job) -> EmbeddingResultFuture: + """ + Retrieves the results of an embedding job. + + Parameters + ---------- + job : Job + The embedding job whose results are to be retrieved. + + Returns + ------- + EmbeddingResultFuture + An instance of EmbeddingResultFuture + """ + return EmbeddingResultFuture(job=job, session=self.session) diff --git a/openprotein/app/services/fold.py b/openprotein/app/services/fold.py new file mode 100644 index 0000000..f466402 --- /dev/null +++ b/openprotein/app/services/fold.py @@ -0,0 +1,89 @@ +"""Application services for Fold.""" + +from openprotein.api import fold +from openprotein.app.models import ( + AlphaFold2Model, + ESMFoldModel, + FoldModel, + FoldResultFuture, +) +from openprotein.base import APISession + + +class FoldAPI: + """ + This class defines a high level interface for accessing the fold API. + """ + + esmfold: ESMFoldModel + alphafold2: AlphaFold2Model + + def __init__(self, session: APISession): + self.session = session + self._load_models() + + @property + def af2(self): + """Alias for AlphaFold2""" + return self.alphafold2 + + def _load_models(self): + # Dynamically add model instances as attributes - precludes any drift + models = self.list_models() + for model in models: + model_name = model.id.replace("-", "_") # hyphens out + setattr(self, model_name, model) + + def list_models(self) -> list[FoldModel]: + """list models available for creating folds of your sequences""" + models = [] + for model_id in fold.fold_models_list_get(self.session): + models.append( + FoldModel.create( + session=self.session, model_id=model_id, default=FoldModel + ) + ) + return models + + def get_model(self, model_id: str) -> FoldModel: + """ + Get model by model_id. + + FoldModel allows all the usual job manipulation: \ + e.g. making POST and GET requests for this model specifically. + + + Parameters + ---------- + model_id : str + the model identifier + + Returns + ------- + FoldModel + The model + + Raises + ------ + HTTPError + If the GET request does not succeed. + """ + return FoldModel.create( + session=self.session, model_id=model_id, default=FoldModel + ) + + def get_results(self, job) -> FoldResultFuture: + """ + Retrieves the results of a fold job. + + Parameters + ---------- + job : Job + The fold job whose results are to be retrieved. + + Returns + ------- + FoldResultFuture + An instance of FoldResultFuture + """ + return FoldResultFuture.create(job=job, session=self.session) diff --git a/openprotein/app/services/job.py b/openprotein/app/services/job.py new file mode 100644 index 0000000..4e431b7 --- /dev/null +++ b/openprotein/app/services/job.py @@ -0,0 +1,48 @@ +from openprotein import config +from openprotein.api import job +from openprotein.app.models import Future +from openprotein.base import APISession +from openprotein.schemas import Job + + +class JobsAPI: + """API wrapper to get jobs.""" + + # This will continue to get jobs, not futures. + + def __init__(self, session: APISession): + self.session = session + + def list( + self, status=None, job_type=None, assay_id=None, more_recent_than=None + ) -> list[Job]: + """List jobs.""" + return [ + Job.create(j) + for j in job.jobs_list( + self.session, + status=status, + job_type=job_type, + assay_id=assay_id, + more_recent_than=more_recent_than, + ) + ] + + def get(self, job_id: str, verbose: bool = False) -> Future: # Job: + """Get job by ID""" + return self.__load(job_id=job_id) + # return Job.create(job.job_get(session=self.session, job_id=job_id)) + + def __load(self, job_id: str) -> Future: + """Loads a job by ID and returns the future.""" + return Future.create(session=self.session, job_id=job_id) + + def wait( + self, + future: Future, + interval=config.POLLING_INTERVAL, + timeout: int | None = None, + verbose: bool = False, + ): + """Waits on a job result.""" + return future.wait(interval=interval, timeout=timeout, verbose=verbose) diff --git a/openprotein/app/services/predict.py b/openprotein/app/services/predict.py new file mode 100644 index 0000000..1c0e908 --- /dev/null +++ b/openprotein/app/services/predict.py @@ -0,0 +1,139 @@ +import logging + +from openprotein.api import predict +from openprotein.app.models import PredictFuture, TrainFuture +from openprotein.base import APISession +from openprotein.errors import InvalidParameterError + +logger = logging.getLogger(__name__) + + +class PredictService: + """interface for calling Predict endpoints""" + + def __init__(self, session: APISession): + """ + Initialize a new instance of the PredictService class. + + Parameters + ---------- + session : APISession + APIsession with auth + """ + self.session = session + + def create_predict_job( + self, + sequences: list[str], + train_job: TrainFuture, + model_ids: list[str] | None = None, + ) -> PredictFuture: + """ + Creates a new Predict job for a given list of sequences and a trained model. + + Parameters + ---------- + sequences : List + The list of sequences to be used for the Predict job. + train_job : Any + The train job object representing the trained model. + model_ids : List[str], optional + The list of model ids to be used for Predict. Default is None. + + Returns + ------- + PredictFuture + The job object representing the Predict job. + + Raises + ------ + InvalidParameterError + If the sequences are not of the same length as the assay data or if the train job has not completed successfully. + InvalidParameterError + If BOTH train_job and model_ids are specified + InvalidParameterError + If NEITHER train_job or model_ids is specified + APIError + If the backend refuses the job (due to sequence length or invalid inputs) + """ + if train_job.assay.sequence_length is not None: + if any([train_job.assay.sequence_length != len(s) for s in sequences]): + raise InvalidParameterError( + f"Predict sequences length {len(sequences[0])} != training assaydata ({train_job.assay.sequence_length})" + ) + if not train_job.done(): + logger.warning( + f"Potential error: Training job has status {train_job.status}" + ) + # raise InvalidParameterError( + # f"train job has status {train_job.status.value}, Predict requires status SUCCESS" + # ) + + return PredictFuture.create( + session=self.session, + job=predict.create_predict_job( + session=self.session, + sequences=sequences, + train_job_id=train_job.id, + model_ids=model_ids, + ), + ) + + def create_predict_single_site( + self, + sequence: str, + train_job: TrainFuture, + model_ids: list[str] | None = None, + ) -> PredictFuture: + """ + Creates a new Predict job for single site mutation analysis with a trained model. + + Parameters + ---------- + sequence : str + The sequence for single site analysis. + train_job : Any + The train job object representing the trained model. + model_ids : List[str], optional + The list of model ids to be used for Predict. Default is None. + + Returns + ------- + PredictFuture + The job object representing the Predict job. + + Raises + ------ + InvalidParameterError + If the sequences are not of the same length as the assay data or if the train job has not completed successfully. + InvalidParameterError + If BOTH train_job and model_ids are specified + InvalidParameterError + If NEITHER train_job or model_ids is specified + APIError + If the backend refuses the job (due to sequence length or invalid inputs) + """ + if train_job.assay is not None: + if train_job.assay.sequence_length is not None: + if any([train_job.assay.sequence_length != len(sequence)]): + raise InvalidParameterError( + f"Predict sequences length {len(sequence)} != training assaydata ({train_job.assay.sequence_length})" + ) + train_job.refresh() + if not train_job.done(): + logger.warning( + f"Potential error: Training job has status {train_job.status}" + ) + # raise InvalidParameterError( + # f"train job has status {train_job.status.value}, Predict requires status SUCCESS" + # ) + + return PredictFuture.create( + session=self.session, + job=predict.create_predict_single_site( + session=self.session, + sequence=sequence, + train_job_id=train_job.id, + model_ids=model_ids, + ), + ) diff --git a/openprotein/app/services/predictor.py b/openprotein/app/services/predictor.py new file mode 100644 index 0000000..55ea1a7 --- /dev/null +++ b/openprotein/app/services/predictor.py @@ -0,0 +1,168 @@ +from openprotein.api import predictor +from openprotein.app.models import ( + AssayDataset, + AssayMetadata, + EmbeddingModel, + PredictorModel, + SVDModel, +) +from openprotein.base import APISession +from openprotein.errors import InvalidParameterError +from openprotein.schemas import FeatureType, ReductionType + +from .embeddings import EmbeddingsAPI +from .svd import SVDAPI + + +class PredictorAPI: + """ + This class defines a high level interface for accessing the predictors API. + """ + + def __init__(self, session: APISession, embeddings: EmbeddingsAPI, svd: SVDAPI): + self.session = session + self.embeddings = embeddings + self.svd = svd + + def get_predictor(self, predictor_id: str) -> PredictorModel: + """ + Get predictor by model_id. + + PredictorModel allows all the usual prediction job manipulation: \ + e.g. making POST and GET requests for this predictor specifically. + + + Parameters + ---------- + predictor_id : str + the model identifier + + Returns + ------- + PredictorModel + The predictor model to inspect and make predictions with. + + Raises + ------ + HTTPError + If the GET request does not succeed. + """ + return PredictorModel( + session=self.session, + metadata=predictor.predictor_get( + session=self.session, predictor_id=predictor_id + ), + ) + + def list_predictors(self) -> list[PredictorModel]: + """ + List predictors available. + + Returns + ------- + list[PredictorModel} + List of predictor models to inspect and make predictions with. + + Raises + ------ + HTTPError + If the GET request does not succeed. + """ + return [ + PredictorModel( + session=self.session, + metadata=m, + ) + for m in predictor.predictor_list(session=self.session) + ] + + def fit_gp( + self, + assay: AssayDataset | AssayMetadata | str, + properties: list[str], + model: EmbeddingModel | SVDModel | str, + feature_type: FeatureType | None = None, + reduction: ReductionType | None = None, + name: str | None = None, + description: str | None = None, + **kwargs, + ) -> PredictorModel: + """ + Fit a GP on an assay with the specified feature model and hyperparameters. + + Parameters + ---------- + assay : AssayMetadata | str + Assay to fit GP on. + properties: list[str] + Properties in the assay to fit the gp on. + feature_type: str + Type of features to use for encoding sequences. "SVD" or "PLM". + model : str + Protembed/SVD model to use depending on feature type. + reduction : str | None + Type of embedding reduction to use for computing features. default = None + prompt: PromptFuture | str | None + Prompt if using PoET-based models. + + Returns + ------- + PredictorModel + The GP model being fit. + """ + # extract feature type + feature_type = ( + FeatureType.PLM + if isinstance(model, EmbeddingModel) + else FeatureType.SVD if isinstance(model, SVDModel) else feature_type + ) + if feature_type is None: + raise InvalidParameterError( + "Expected feature_type to be provided if passing str model_id as model" + ) + # get model if model_id + if feature_type == FeatureType.PLM: + if reduction is None: + raise InvalidParameterError( + "Expected reduction if using EmbeddingModel" + ) + if isinstance(model, str): + model = self.embeddings.get_model(model) + assert isinstance(model, EmbeddingModel), "Expected EmbeddingModel" + return model.fit_gp( + assay=assay, + properties=properties, + reduction=reduction, + name=name, + description=description, + **kwargs, + ) + elif feature_type == FeatureType.SVD: + if isinstance(model, str): + model = self.svd.get_svd(model) + assert isinstance(model, SVDModel), "Expected SVDModel" + return model.fit_gp( + assay=assay, + properties=properties, + name=name, + description=description, + **kwargs, + ) + + def __delete_predictor(self, predictor_id: str) -> bool: + """ + Delete predictor model. + + Parameters + ---------- + predictor_id : str + The ID of the predictor. + Returns + ------- + bool + True: successful deletion + + """ + return predictor.predictor_delete( + session=self.session, predictor_id=predictor_id + ) diff --git a/openprotein/app/services/svd.py b/openprotein/app/services/svd.py new file mode 100644 index 0000000..f2f780a --- /dev/null +++ b/openprotein/app/services/svd.py @@ -0,0 +1,102 @@ +from openprotein.api import svd +from openprotein.app.models import AssayDataset, SVDModel +from openprotein.base import APISession +from openprotein.errors import InvalidParameterError +from openprotein.schemas import ReductionType + +from .embeddings import EmbeddingsAPI + + +class SVDAPI: + + def __init__(self, session: APISession, embeddings: EmbeddingsAPI): + self.session = session + self.embeddings = embeddings + + def fit_svd( + self, + model_id: str, + sequences: list[bytes] | None = None, + assay: AssayDataset | None = None, + n_components: int = 1024, + reduction: ReductionType | None = None, + **kwargs, + ) -> SVDModel: + """ + Fit an SVD on the sequences with the specified model_id and hyperparameters (n_components). + + Parameters + ---------- + model_id : str + The ID of the model to fit the SVD on. + sequences : list[bytes] + The list of sequences to use for the SVD fitting. + n_components : int, optional + The number of components for the SVD, by default 1024. + reduction : str, optional + The reduction method to apply to the embeddings, by default None. + + Returns + ------- + SVDModel + The model with the SVD fit. + """ + model = self.embeddings.get_model(model_id) + return model.fit_svd( + sequences=sequences, + assay=assay, + n_components=n_components, + reduction=reduction, + **kwargs, + ) + + def get_svd(self, svd_id: str) -> SVDModel: + """ + Get SVD job results. Including SVD dimension and sequence lengths. + + Requires a successful SVD job from fit_svd + + Parameters + ---------- + svd_id : str + The ID of the SVD job. + Returns + ------- + SVDModel + The model with the SVD fit. + """ + metadata = svd.svd_get(self.session, svd_id) + return SVDModel(session=self.session, metadata=metadata) + + def __delete_svd(self, svd_id: str) -> bool: + """ + Delete SVD model. + + Parameters + ---------- + svd_id : str + The ID of the SVD job. + Returns + ------- + bool + True: successful deletion + + """ + return svd.svd_delete(self.session, svd_id) + + def list_svd(self) -> list[SVDModel]: + """ + List SVD models made by user. + + Takes no args. + + Returns + ------- + list[SVDModel] + SVDModels + + """ + return [ + SVDModel(session=self.session, metadata=metadata) + for metadata in svd.svd_list_get(self.session) + ] diff --git a/openprotein/app/services/train.py b/openprotein/app/services/train.py new file mode 100644 index 0000000..0316608 --- /dev/null +++ b/openprotein/app/services/train.py @@ -0,0 +1,135 @@ +from openprotein.api import train +from openprotein.app.models import AssayDataset, TrainFuture +from openprotein.base import APISession +from openprotein.errors import InvalidParameterError + + +class TrainingAPI: + """API interface for calling Train endpoints""" + + def __init__( + self, + session: APISession, + ): + self.session = session + self.assay = None + + def create_training_job( + self, + assaydataset: AssayDataset, + measurement_name: str | list[str], + model_name: str = "", + force_preprocess: bool = False, + ) -> TrainFuture: + """ + Create a training job on your data. + + This function validates the inputs, formats the data, and sends the job. + + Parameters + ---------- + assaydataset : AssayDataset + An AssayDataset object from which the assay_id is extracted. + measurement_name : str or List[str] + The name(s) of the measurement(s) to be used in the training job. + model_name : str, optional + The name to give the model. + force_preprocess : bool, optional + If set to True, preprocessing is forced even if data already exists. + + Returns + ------- + TrainFuture + A TrainFuture Job + + Raises + ------ + InvalidParameterError + If the `assaydataset` is not an AssayDataset object, + If any measurement name provided does not exist in the AssayDataset, + or if the AssayDataset has fewer than 3 data points. + HTTPError + If the request to the server fails. + """ + if isinstance(measurement_name, str): + measurement_name = [measurement_name] + + # ensure measurements exist in dataset + for measurement in measurement_name: + if measurement not in assaydataset.measurement_names: + raise InvalidParameterError(f"No {measurement} in measurement names") + + # ensure assaydataset is large enough + if assaydataset.shape[0] < 3: + raise InvalidParameterError( + "Assaydata must have >=3 data points for training!" + ) + + return TrainFuture.create( + session=self.session, + job=train.create_train_job( + session=self.session, + assay_id=assaydataset.id, + measurement_name=measurement_name, + model_name=model_name, + force_preprocess=force_preprocess, + ), + ) + + def _create_training_job_br( + self, + assaydataset: AssayDataset, + measurement_name: str | list[str], + model_name: str = "", + force_preprocess: bool = False, + ) -> TrainFuture: + """Same as create_training_job.""" + return TrainFuture.create( + session=self.session, + job=train._create_train_job_br( + session=self.session, + assay_id=assaydataset.id, + measurement_name=measurement_name, + model_name=model_name, + force_preprocess=force_preprocess, + ), + ) + + def _create_training_job_gp( + self, + assaydataset: AssayDataset, + measurement_name: str | list[str], + model_name: str = "", + force_preprocess: bool = False, + ) -> TrainFuture: + """Same as create_training_job.""" + return TrainFuture.create( + session=self.session, + job=train._create_train_job_gp( + session=self.session, + assay_id=assaydataset.id, + measurement_name=measurement_name, + model_name=model_name, + force_preprocess=force_preprocess, + ), + ) + + def get_training_results(self, job_id: str) -> TrainFuture: + """ + Get training results (e.g. loss etc). + + Parameters + ---------- + job_id : str + job_id to get + + + Returns + ------- + TrainFuture + A TrainFuture Job + """ + train_job = train.get_training_results(self.session, job_id) + return TrainFuture.create( + session=self.session, job=train_job, traingraph=train_job.traingraph + ) diff --git a/openprotein/base.py b/openprotein/base.py index 6a77fa6..9600720 100644 --- a/openprotein/base.py +++ b/openprotein/base.py @@ -1,12 +1,12 @@ -import openprotein.config as config - -import requests -from urllib.parse import urljoin from typing import Union +from urllib.parse import urljoin +import requests from requests.adapters import HTTPAdapter from requests.packages.urllib3.util.retry import Retry -from openprotein.errors import HTTPError, APIError, AuthError + +import openprotein.config as config +from openprotein.errors import APIError, AuthError, HTTPError class BearerAuth(requests.auth.AuthBase): @@ -38,23 +38,28 @@ class APISession(requests.Session): >>> session = APISession("username", "password") """ - def __init__(self, username:str, - password:str, - backend:str = "https://api.openprotein.ai/api/", - timeout:int = 180): + def __init__( + self, + username: str, + password: str, + backend: str = "https://api.openprotein.ai/api/", + timeout: int = 180, + ): super().__init__() self.backend = backend self.verify = True self.timeout = timeout # Custom retry strategies - #auto retry for pesky connection reset errors and others + # auto retry for pesky connection reset errors and others # 503 will catch if BE is refreshing - retry = Retry(total=4, - backoff_factor=3, #0,1,4,13s - status_forcelist=[500, 502, 503, 504, 101, 104]) + retry = Retry( + total=4, + backoff_factor=3, # 0,1,4,13s + status_forcelist=[500, 502, 503, 504, 101, 104], + ) adapter = HTTPAdapter(max_retries=retry) - self.mount('https://', adapter) + self.mount("https://", adapter) self.login(username, password) def post(self, url, data=None, json=None, **kwargs): @@ -68,30 +73,27 @@ def post(self, url, data=None, json=None, **kwargs): :rtype: requests.Response """ timeout = self.timeout - if 'timeout' in kwargs: - timeout = kwargs.pop('timeout') - - return self.request("POST", - url, - data=data, - json=json, - timeout=timeout, - **kwargs) - - def login(self, username:str, password:str): - """ + if "timeout" in kwargs: + timeout = kwargs.pop("timeout") + + return self.request( + "POST", url, data=data, json=json, timeout=timeout, **kwargs + ) + + def login(self, username: str, password: str): + """ Authenticate connection to OpenProtein with your credentials. - + Parameters ----------- username: str - username + username password: str password """ self.auth = self._get_auth_token(username, password) - def _get_auth_token(self, username:str, password:str): + def _get_auth_token(self, username: str, password: str): endpoint = "v1/login/access-token" url = urljoin(self.backend, endpoint) try: @@ -106,9 +108,9 @@ def _get_auth_token(self, username:str, password:str): result = response.json() token = result.get("access_token") - if token is None: + if token is None: raise AuthError("Unable to authenticate with given credentials.") - return BearerAuth(token) + return BearerAuth(token) def request( self, method: Union[str, bytes], url: Union[str, bytes], *args, **kwargs diff --git a/openprotein/config.py b/openprotein/config.py index 8b6dfd0..839e698 100644 --- a/openprotein/config.py +++ b/openprotein/config.py @@ -7,6 +7,3 @@ POET_PAGE_SIZE = 50000 POET_MAX_PAGE_SIZE = 50000 EMBEDDING_PAGE_SIZE = 16 - -# Registry for job types to auto-infer job -JOB_REGISTRY = {} \ No newline at end of file diff --git a/openprotein/errors.py b/openprotein/errors.py index f666653..3695f1b 100644 --- a/openprotein/errors.py +++ b/openprotein/errors.py @@ -1,45 +1,62 @@ +from requests import Response + + # Errors for OpenProtein class InvalidParameterError(Exception): """InvalidParameterError""" + def __init__(self, message="Invalid parameter"): self.message = message super().__init__(self.message) + class MissingParameterError(Exception): """MissingParameterError""" + def __init__(self, message="Required parameter is missing"): self.message = message super().__init__(self.message) - + + class APIError(Exception): """APIError""" + def __init__(self, message: str): self.message = message super().__init__(self.message) + class HTTPError(APIError): - def __init__(self, response): + def __init__(self, response: Response): self.response = response self.status_code = response.status_code self.text = response.text self.url = response.url - message = f"Status code {self.status_code}\non resource: {self.url}\n{self.text}" + message = ( + f"Status code {self.status_code}\non resource: {self.url}\n{self.text}" + ) super().__init__(message) + class AuthError(Exception): """InvalidParameterError""" + def __init__(self, message="Invalid authorization"): self.message = message super().__init__(self.message) - + + class InvalidJob(Exception): """InvalidParameterError""" + def __init__(self, message="No such job"): self.message = message super().__init__(self.message) + class TimeoutException(Exception): """InvalidParameterError""" + def __init__(self, message="Request timed out!"): self.message = message - super().__init__(self.message) \ No newline at end of file + super().__init__(self.message) diff --git a/openprotein/futures.py b/openprotein/futures.py deleted file mode 100644 index 3cb39cd..0000000 --- a/openprotein/futures.py +++ /dev/null @@ -1,60 +0,0 @@ -# Store for Model and Future classes -from openprotein.jobs import job_get, ResultsParser -from typing import Optional, Any - - -class FutureBase: - """Base class for all Future classes. - - This class needs to be directly inherited for class discovery.""" - - # overridden by subclasses - job_type: Optional[Any] = None - - @classmethod - def get_job_type(cls): - """Return the job type associated with this Future class.""" - - if isinstance(cls.job_type, str): - return [cls.job_type] - return cls.job_type - - -class FutureFactory: - """Factory class for creating Future instances based on job_type.""" - - @staticmethod - def create_future( - session, job_id: Optional[str] = None, response: Optional[dict] = None, **kwargs - ): - """ - Create and return an instance of the appropriate Future class based on the job type. - - Parameters: - - job: The job object containing the job_type attribute. - - session: sess for API interactions. - - **kwargs: Additional keyword arguments to pass to the Future class constructor. - - Returns: - - An instance of the appropriate Future class. - """ - - # parse job - if job_id: - job = job_get(session, job_id) - else: - if "job" not in kwargs: - job = ResultsParser.parse_obj(response) - else: - job = kwargs.pop("job") - - # Dynamically discover all subclasses of FutureBase - future_classes = FutureBase.__subclasses__() - kwargs = {k: v for k, v in kwargs.items() if v is not None} - - # Find the Future class that matches the job type - for future_class in future_classes: - if job.job_type in future_class.get_job_type(): - return future_class(session=session, job=job, **kwargs) # type: ignore - - raise ValueError(f"Unsupported job type: {job.job_type}") diff --git a/openprotein/jobs.py b/openprotein/jobs.py deleted file mode 100644 index 73fd6aa..0000000 --- a/openprotein/jobs.py +++ /dev/null @@ -1,186 +0,0 @@ -from datetime import datetime -from typing import Optional, Literal -import time -from openprotein.pydantic import BaseModel, Field -from openprotein.errors import TimeoutException -from openprotein.base import APISession -import openprotein.config as config -import tqdm -from requests import Response -from openprotein.config import JOB_REGISTRY -from openprotein.schemas import JobStatus, JobType - - -class Job(BaseModel): - status: JobStatus - job_id: Optional[str] # must be optional as predict can return None - # new emb service get doesnt have job_type - job_type: Optional[Literal[tuple(member.value for member in JobType.__members__.values())]] # type: ignore - created_date: Optional[datetime] = None - start_date: Optional[datetime] = None - end_date: Optional[datetime] = None - prerequisite_job_id: Optional[str] = None - progress_message: Optional[str] = None - progress_counter: Optional[int] = 0 - num_records: Optional[int] = None - sequence_length: Optional[int] = None - - def refresh(self, session: APISession): - """refresh job status""" - return job_get(session, self.job_id) - - def done(self) -> bool: - """Check if job is complete""" - return self.status.done() - - def cancelled(self) -> bool: - """check if job is cancelled""" - return self.status.cancelled() - - def _update_progress(self, job) -> int: - """update rules for jobs without counters""" - progress = job.progress_counter - # if progress is not None: # Check None before comparison - if progress is None: - if job.status == JobStatus.PENDING: - progress = 5 - if job.status == JobStatus.RUNNING: - progress = 25 - if job.status in [JobStatus.SUCCESS, JobStatus.FAILURE]: - progress = 100 - return progress or 0 # never None - - def wait( - self, - session: APISession, - interval: int = config.POLLING_INTERVAL, - timeout: Optional[int] = None, - verbose: bool = False, - ): - """ - Wait for a job to finish, and then get the results. - - Args: - session (APISession): Auth'd APIsession - interval (int): Wait between polls (secs). Defaults to POLLING_INTERVAL - timeout (int): Max. time to wait before raising error. Defaults to unlimited. - verbose (bool, optional): print status updates. Defaults to False. - - Raises: - TimeoutException: _description_ - - Returns: - _type_: _description_ - """ - start_time = time.time() - - def is_done(job: Job): - if timeout is not None: - elapsed_time = time.time() - start_time - if elapsed_time >= timeout: - raise TimeoutException( - f"Wait time exceeded timeout {timeout}, waited {elapsed_time}" - ) - return job.done() - - pbar = None - if verbose: - pbar = tqdm.tqdm(total=100, desc="Waiting", position=0) - - job = self.refresh(session) - while not is_done(job): - if verbose: - # pbar.update(1) - # pbar.set_postfix({"status": job.status}) - progress = self._update_progress(job) - pbar.n = progress - pbar.set_postfix({"status": job.status}) - # pbar.refresh() - # print(f'Retry {retries}, status={self.job.status}, time elapsed {time.time() - start_time:.2f}') - time.sleep(interval) - job = job.refresh(session) - - if verbose: - # pbar.update(1) - # pbar.set_postfix({"status": job.status}) - - progress = self._update_progress(job) - pbar.n = progress - pbar.set_postfix({"status": job.status}) - # pbar.refresh() - - return job - - wait_until_done = wait - - -class JobDetails(BaseModel): - job_id: str - job_type: str - status: str - - -def register_job_type(job_type: str): - def decorator(cls): - JOB_REGISTRY[job_type] = cls - return cls - - return decorator - - -@register_job_type(JobType.workflow_design) -class DesignJob(Job): - job_id: Optional[str] = None - job_type: Literal[JobType.workflow_design] = JobType.workflow_design - - -# old and new style names -@register_job_type(JobType.embeddings_svd) -@register_job_type(JobType.svd_fit) -@register_job_type(JobType.svd_embed) -class SVDJob(Job): - job_type: Literal[JobType.embeddings_svd, JobType.svd_fit, JobType.svd_embed] = ( - JobType.embeddings_svd - ) - - -class ResultsParser(BaseModel): - """Polymorphic class to parse results from GET correctly""" - - __root__: Job = Field(...) - - @classmethod - def parse_obj(cls, obj, **kwargs): - try: - if isinstance(obj, Response): - obj = obj.json() - # Determine the correct job class based on the job_type field - job_type = obj.get("job_type") - job_class = JOB_REGISTRY.get(job_type) - if job_class: - return job_class.parse_obj(obj, **kwargs) - else: - raise ValueError(f"Unknown job type: {job_type}") - except Exception as e: - # default to Job class - return Job.parse_obj(obj, **kwargs) - - -class SpecialPredictJob(Job): - """special case of Job for predict that doesnt require job_id""" - - job_id: Optional[str] = None - - -def job_args_get(session: APISession, job_id) -> dict: - """Get job.""" - endpoint = f"v1/jobs/{job_id}/args" - response = session.get(endpoint) - return dict(**response.json()) - - -def job_get(session: APISession, job_id) -> Job: - """Get job.""" - endpoint = f"v1/jobs/{job_id}" - response = session.get(endpoint) - return ResultsParser.parse_obj(response) diff --git a/openprotein/pydantic.py b/openprotein/pydantic.py deleted file mode 100644 index 295e901..0000000 --- a/openprotein/pydantic.py +++ /dev/null @@ -1,20 +0,0 @@ -try: - from pydantic.v1 import ( - BaseModel, - Field, - ConfigDict, - validator, - root_validator, - parse_obj_as, - ) - import pydantic.v1 as pydantic -except ImportError: - from pydantic import ( - BaseModel, - Field, - ConfigDict, - validator, - root_validator, - parse_obj_as, - ) - import pydantic diff --git a/openprotein/schemas.py b/openprotein/schemas.py deleted file mode 100644 index 1af4968..0000000 --- a/openprotein/schemas.py +++ /dev/null @@ -1,55 +0,0 @@ -from openprotein.pydantic import BaseModel, ConfigDict -from enum import Enum - - -class JobType(str, Enum): - """ - Type of job. - - Describes the types of jobs that can be done. - """ - - stub = "stub" - - workflow_preprocess = "/workflow/preprocess" - workflow_train = "/workflow/train" - workflow_embed_umap = "/workflow/embed/umap" - workflow_predict = "/workflow/predict" - worflow_predict_single_site = "/workflow/predict/single_site" - workflow_crossvalidate = "/workflow/crossvalidate" - workflow_evaluate = "/workflow/evaluate" - workflow_design = "/workflow/design" - - align_align = "/align/align" - align_prompt = "/align/prompt" - poet_score = "/poet" - poet_single_site = "/poet/single_site" - poet_generate = "/poet/generate" - - embeddings_embed = "/embeddings/embed" - embeddings_svd = "/embeddings/svd" - embeddings_attn = "/embeddings/attn" - embeddings_logits = "/embeddings/logits" - embeddings_embed_reduced = "/embeddings/embed_reduced" - - svd_fit = "/svd/fit" - svd_embed = "/svd/embed" - - embeddings_fold = "/embeddings/fold" - - -class JobStatus(str, Enum): - PENDING: str = "PENDING" - RUNNING: str = "RUNNING" - SUCCESS: str = "SUCCESS" - FAILURE: str = "FAILURE" - RETRYING: str = "RETRYING" - CANCELED: str = "CANCELED" - - def done(self): - return ( - (self is self.SUCCESS) or (self is self.FAILURE) or (self is self.CANCELED) - ) # noqa: E501 - - def cancelled(self): - return self is self.CANCELED diff --git a/openprotein/schemas/__init__.py b/openprotein/schemas/__init__.py new file mode 100644 index 0000000..dcdadc3 --- /dev/null +++ b/openprotein/schemas/__init__.py @@ -0,0 +1,67 @@ +""" +OpenProtein schemas for interacting with the system. + +isort:skip_file +""" + +from .job import Job, JobStatus, JobType +from .assaydata import AssayDataPage, AssayMetadata +from .train import ( + CVItem as WorkflowCVItem, + CVJob as WorkflowCVJob, + TrainJob as WorkflowTrainJob, + TrainStep as WorkflowTrainStep, +) +from .predict import ( + PredictJob as WorkflowPredictJob, + PredictSingleSiteJob as WorkflowPredictSingleSiteJob, +) +from .design import ( + ModelCriterion, + Criterion, + DesignJobCreate, + DesignMetadata, + DesignResults, + DesignStep, + DesignJob, +) +from .deprecated.poet import ( + PoetScoreJob, + PoetSSPJob, + PoetScoreResult, + PoetSSPResult, + PoetGenerateJob, +) +from .align import MSAJob, MSASamplingMethod, PoetInputType, PromptJob, PromptPostParams +from .embeddings import ( + ModelMetadata, + ModelDescription, + TokenInfo, + ReductionType, + EmbeddedSequence, + EmbeddingsJob, + AttnJob, + LogitsJob, + ScoreJob, + ScoreSingleSiteJob, + GenerateJob, +) +from .fold import FoldJob +from .svd import ( + SVDMetadata, + FitJob, + EmbeddingsJob as SVDEmbeddingsJob, +) +from .predictor import ( + Constraints, + CVJob, + FeatureType, + Kernel, + PredictJob, + PredictMultiJob, + PredictMultiSingleSiteJob, + PredictorArgs, + PredictorMetadata, + PredictSingleSiteJob, + TrainJob, +) diff --git a/openprotein/schemas/align.py b/openprotein/schemas/align.py new file mode 100644 index 0000000..dfadd76 --- /dev/null +++ b/openprotein/schemas/align.py @@ -0,0 +1,49 @@ +from enum import Enum +from typing import Literal + +from pydantic import BaseModel, Field + +from .job import Job, JobType + + +class PoetInputType(str, Enum): + INPUT = "RAW" + MSA = "GENERATED" + PROMPT = "PROMPT" + + +class MSASamplingMethod(str, Enum): + RANDOM = "RANDOM" + NEIGHBORS = "NEIGHBORS" + NEIGHBORS_NO_LIMIT = "NEIGHBORS_NO_LIMIT" + NEIGHBORS_NONGAP_NORM_NO_LIMIT = "NEIGHBORS_NONGAP_NORM_NO_LIMIT" + TOP = "TOP" + + +class PromptPostParams(BaseModel): + msa_id: str + num_sequences: int | None = Field(None, ge=0, lt=100) + num_residues: int | None = Field(None, ge=0, lt=24577) + method: MSASamplingMethod = MSASamplingMethod.NEIGHBORS_NONGAP_NORM_NO_LIMIT + homology_level: float = Field(0.8, ge=0, le=1) + max_similarity: float = Field(1.0, ge=0, le=1) + min_similarity: float = Field(0.0, ge=0, le=1) + always_include_seed_sequence: bool = False + num_ensemble_prompts: int = 1 + random_seed: int | None = None + + +class MSAJob(Job): + job_type: Literal[JobType.align_align] + + @property + def msa_id(self): + return self.msa_id + + +class PromptJob(MSAJob, Job): + job_type: Literal[JobType.align_prompt] + + @property + def prompt_id(self): + return self.job_id diff --git a/openprotein/schemas/assaydata.py b/openprotein/schemas/assaydata.py new file mode 100644 index 0000000..f44e715 --- /dev/null +++ b/openprotein/schemas/assaydata.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class AssayMetadata(BaseModel): + assay_name: str + assay_description: str + assay_id: str + original_filename: str + created_date: datetime + num_rows: int + num_entries: int + measurement_names: list[str] + sequence_length: int | None = None + + +class AssayDataRow(BaseModel): + mut_sequence: str + measurement_values: list[float | None] + + +class AssayDataPage(BaseModel): + assaymetadata: AssayMetadata + page_size: int + page_offset: int + assaydata: list[AssayDataRow] diff --git a/openprotein/schemas/deprecated/__init__.py b/openprotein/schemas/deprecated/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openprotein/schemas/deprecated/poet.py b/openprotein/schemas/deprecated/poet.py new file mode 100644 index 0000000..ae5640e --- /dev/null +++ b/openprotein/schemas/deprecated/poet.py @@ -0,0 +1,70 @@ +from typing import Literal + +from pydantic import BaseModel, Field, field_validator + +from ..job import Job, JobType + + +class PoetSSPResult(BaseModel): + sequence: bytes = Field(validate_default=True) + score: list[float] + name: str | None = Field(default=None, validate_default=True) + _n: int = 0 + + @field_validator("sequence", mode="before") + def replace_sequence(cls, value): + """Rename X0X which refers to base sequence.""" + if "X0X" in str(value): + return b"WT" + return value + + @field_validator("name", mode="before") + def increment_name(cls, value): + if value is None: + cls._n += 1 + return f"Mutant{cls._n}" + return value + + +class PoetScoreResult(BaseModel): + sequence: bytes + score: list[float] + name: str | None = None + + +class PoetScoreJob(Job): + parent_id: str | None = None + s3prefix: str | None = None + page_size: int | None = None + page_offset: int | None = None + num_rows: int | None = None + result: list[PoetScoreResult] | None = None + n_completed: int | None = None + + job_type: Literal[JobType.poet] + + +# HACK - dont inherit directly so auto-parser doesnt find it +class PoetSSPJob(PoetScoreJob): + parent_id: str | None = None + s3prefix: str | None = None + page_size: int | None = None + page_offset: int | None = None + num_rows: int | None = None + result: list[PoetSSPResult] | None = None + n_completed: int | None = None + + job_type: Literal[JobType.poet_single_site] + + +# HACK - dont inherit directly so auto-parser doesnt find it +class PoetGenerateJob(PoetScoreJob): + parent_id: str | None = None + s3prefix: str | None = None + page_size: int | None = None + page_offset: int | None = None + num_rows: int | None = None + result: list[PoetScoreResult] | None = None + n_completed: int | None = None + + job_type: Literal[JobType.poet_generate] diff --git a/openprotein/schemas/design.py b/openprotein/schemas/design.py new file mode 100644 index 0000000..d36aeec --- /dev/null +++ b/openprotein/schemas/design.py @@ -0,0 +1,251 @@ +import re +from datetime import datetime +from enum import Enum +from typing import Literal + +from pydantic import BaseModel, Field, field_validator + +from .job import Job, JobType + + +class DesignMetadata(BaseModel): + y_mu: float | None = None + y_var: float | None = None + + +class DesignSubscore(BaseModel): + score: float + metadata: DesignMetadata + + +class DesignStep(BaseModel): + step: int + sample_index: int + sequence: str + # scores: List[int] + # subscores_metadata: List[List[DesignSubscore]] + # scores: list[float] + scores: list[list[DesignSubscore]] = Field(..., alias="subscores_metadata") + # umap1: float + # umap2: float + + +class DesignResults(BaseModel): + status: str + job_id: str + job_type: str + created_date: datetime + start_date: datetime + end_date: datetime | None + assay_id: str + num_rows: int + result: list[DesignStep] + + +class DirectionEnum(str, Enum): + gt = ">" + lt = "<" + eq = "=" + + +class Criterion(BaseModel): + target: float + weight: float + direction: str + + +class ModelCriterion(BaseModel): + criterion_type: Literal["model"] + model_id: str + measurement_name: str + criterion: Criterion + + class Config: + protected_namespaces = () + + +class NMutationCriterion(BaseModel): + criterion_type: Literal["n_mutations"] + # sequences: list[str] | None + + +CriterionItem = ModelCriterion | NMutationCriterion + + +class DesignConstraint: + def __init__(self, sequence: str): + self.sequence = sequence + self.mutations = self.initialize(sequence) + + def initialize(self, sequence: str) -> dict[int, list[str]]: + """Initialize with no changes allowed to the sequence.""" + return {i: [aa] for i, aa in enumerate(sequence, start=1)} + + def allow(self, positions: int | list[int], amino_acids: list[str] | str) -> None: + """Allow specific amino acids at given positions.""" + if isinstance(positions, int): + positions = [positions] + if isinstance(amino_acids, str): + amino_acids = list(amino_acids) + + for position in positions: + if position in self.mutations: + self.mutations[position].extend(amino_acids) + else: + self.mutations[position] = amino_acids + + def remove(self, positions: int | list[int], amino_acids: list[str] | str) -> None: + """Remove specific amino acids from being allowed at given positions.""" + if isinstance(positions, int): + positions = [positions] + if isinstance(amino_acids, str): + amino_acids = list(amino_acids) + + for position in positions: + if position in self.mutations: + for aa in amino_acids: + if aa in self.mutations[position]: + self.mutations[position].remove(aa) + + def as_dict(self) -> dict[int, list[str]]: + """Convert the internal mutations representation into a dictionary.""" + return self.mutations + + +class DesignJobCreate(BaseModel): + assay_id: str + criteria: list[list[CriterionItem]] + num_steps: int | None = 8 + pop_size: int | None = None + n_offsprings: int | None = None + crossover_prob: float | None = None + crossover_prob_pointwise: float | None = None + mutation_average_mutations_per_seq: int | None = None + allowed_tokens: DesignConstraint | dict[int, list[str]] | None = None + + class Config: + arbitrary_types_allowed = True + + @field_validator("allowed_tokens", mode="before") + def ensure_dict(cls, v): + if isinstance(v, DesignConstraint): + return v.as_dict() + return v + + +def _validate_mutation_dict(d: dict, amino_acids: str = "ACDEFGHIKLMNPQRSTVWY"): + validated = {} + for k, v in d.items(): + _ = [i for i in v if i in amino_acids] + validated[k] = _ + return validated + + +def mutation_regex( + constraints: str, + amino_acids: list[str] | str = "ACDEFGHIKLMNPQRSTVWY", + verbose: bool = False, +) -> dict: + """ + Parses a constraint string for sequence and return a mutation dict. + + Syntax supported: + * [AC] - position must be A or C ONLY + * X - position can be any amino acid + * A - position will always be A + * [^ACD] - anything except A, C or D + * X{3} - 3 consecutive positions of any residue + * A{3} - 3 consecutive positions of A + + Parameters + ---------- + constraints: A string representing the constraints on the protein sequence. + amino_acids: A list or string of all possible amino acids. + verbose: control verbosity + + Returns + ------- + dict : mutation dict + """ + if isinstance(amino_acids, str): + amino_acids = list(amino_acids) + constraints_dict = {} + + constraints_dict = {} + pos = 1 + + pattern = re.compile( + r"(\[[^\]]*\])|(\{[A-Z]+\})|([A-Z]\{\d+\})|([A-Z]\{\d+,\d*\})|(X\{\d+\})|([A-Z])|(X)" + ) + + for match in pattern.finditer(constraints): + token = match.group() + if verbose: + print(f"parsed: {token}") + + if token.startswith("[") and token.endswith("]"): + if "^" in token: + # Negation + excluded = set(token[2:-1]) + options = [aa for aa in amino_acids if aa not in excluded] + else: + # Specific options + options = list(token[1:-1]) + constraints_dict[pos] = options + pos += 1 + elif token.startswith("{") and token.endswith("}"): + # Ranges of positions or exact repetitions for specific amino acids + options = list(token[1:-1]) + constraints_dict[pos] = options + pos += 1 + elif "{" in token and "X" not in token: + # Ranges of positions or exact repetitions for specific amino acids + base, range_part = token.split("{") + if "," in range_part: + # Range specified, handle similarly to previous versions + start, end = map(int, range_part[:-1].split(",")) + for _ in range(start, end + 1): + constraints_dict[pos] = [base] + pos += 1 + else: + # Exact repetition specified + count = int(range_part[:-1]) + for _ in range(count): + constraints_dict[pos] = [base] + pos += 1 + elif token.startswith("X{") and token.endswith("}"): + # Fixed number of wildcard positions + num = int(token[2:-1]) + for _ in range(num): + constraints_dict[pos] = list(amino_acids) + pos += 1 + elif token == "X": + # Any amino acid + constraints_dict[pos] = list(amino_acids) + pos += 1 + else: + # Specific amino acid + constraints_dict[pos] = [token] + pos += 1 + + return _validate_mutation_dict(constraints_dict) + + +def position_mutation( + positions: list, amino_acids: str | list = "ACDEFGHIKLMNPQRSTVWY" +): + if isinstance(amino_acids, list): + amino_acids = "".join(amino_acids) + return {k: list(amino_acids) for k in positions} + + +def no_change(sequence: str): + return {k + 1: [v] for k, v in enumerate(sequence)} + + +def keep_cys(sequence: str): + return {k + 1: [v] for k, v in enumerate(sequence) if v == "C"} + + +class DesignJob(Job): + job_type: Literal[JobType.workflow_design] diff --git a/openprotein/schemas/embeddings.py b/openprotein/schemas/embeddings.py new file mode 100644 index 0000000..26df243 --- /dev/null +++ b/openprotein/schemas/embeddings.py @@ -0,0 +1,103 @@ +"""Schemas for embeddings.""" + +from enum import Enum +from typing import Literal + +import numpy as np +from pydantic import BaseModel, Field + +from .job import BatchJob, Job, JobType + + +class ModelDescription(BaseModel): + """Description of available protein embedding models.""" + + citation_title: str | None = None + doi: str | None = None + summary: str = "Protein language model for embeddings" + + +class TokenInfo(BaseModel): + """Information about the tokens used in the embedding model.""" + + id: int + token: str + primary: bool + description: str + + +class ModelMetadata(BaseModel): + """Metadata about available protein embedding models.""" + + id: str = Field(..., alias="model_id") + description: ModelDescription + max_sequence_length: int | None = None + dimension: int + output_types: list[str] + input_tokens: list[str] + output_tokens: list[str] | None = None + token_descriptions: list[list[TokenInfo]] + + +class ReductionType(str, Enum): + MEAN = "MEAN" + SUM = "SUM" + + +class EmbeddedSequence(BaseModel): + """ + Representation of an embedded sequence created from our models. + + Represented as an iterable yielding the sequence followed by the embedding. + """ + + class Config: + arbitrary_types_allowed = True + + sequence: bytes + embedding: np.ndarray + + def __iter__(self): + yield self.sequence + yield self.embedding + + def __len__(self): + return 2 + + def __getitem__(self, i): + if i == 0: + return self.sequence + elif i == 1: + return self.embedding + + +class EmbeddingsJob(Job, BatchJob): + + job_type: Literal[JobType.embeddings_embed, JobType.embeddings_embed_reduced] = ( + JobType.embeddings_embed + ) + + +class AttnJob(Job, BatchJob): + + job_type: Literal[JobType.embeddings_attn] + + +class LogitsJob(Job, BatchJob): + + job_type: Literal[JobType.embeddings_logits] + + +class ScoreJob(Job, BatchJob): + + job_type: Literal[JobType.poet_score] + + +class ScoreSingleSiteJob(Job, BatchJob): + + job_type: Literal[JobType.poet_single_site] + + +class GenerateJob(Job, BatchJob): + + job_type: Literal[JobType.poet_generate] diff --git a/openprotein/schemas/fold.py b/openprotein/schemas/fold.py new file mode 100644 index 0000000..46f2794 --- /dev/null +++ b/openprotein/schemas/fold.py @@ -0,0 +1,10 @@ +"""OpenProtein schemas for Fold.""" + +from typing import Literal + +from .job import BatchJob, Job, JobType + + +class FoldJob(Job, BatchJob): + + job_type: Literal[JobType.embeddings_fold] diff --git a/openprotein/schemas/job.py b/openprotein/schemas/job.py new file mode 100644 index 0000000..3e11d17 --- /dev/null +++ b/openprotein/schemas/job.py @@ -0,0 +1,123 @@ +import logging +from datetime import datetime +from enum import Enum +from typing import Union + +from pydantic import BaseModel, ConfigDict, TypeAdapter +from requests import Response +from typing_extensions import Self + +logger = logging.getLogger(__name__) + + +class JobType(str, Enum): + """ + Type of job. + + Describes the types of jobs that can be done. + """ + + stub = "stub" + + workflow_preprocess = "/workflow/preprocess" + workflow_train = "/workflow/train" + workflow_embed_umap = "/workflow/embed/umap" + workflow_predict = "/workflow/predict" + workflow_predict_single_site = "/workflow/predict/single_site" + workflow_crossvalidate = "/workflow/crossvalidate" + workflow_evaluate = "/workflow/evaluate" + workflow_design = "/workflow/design" + + align_align = "/align/align" + align_prompt = "/align/prompt" + poet = "/poet" + poet_score = "/poet/score" + poet_single_site = "/poet/single_site" + poet_generate = "/poet/generate" + + embeddings_embed = "/embeddings/embed" + embeddings_svd = "/embeddings/svd" + embeddings_attn = "/embeddings/attn" + embeddings_logits = "/embeddings/logits" + embeddings_embed_reduced = "/embeddings/embed_reduced" + + svd_fit = "/svd/fit" + svd_embed = "/svd/embed" + + embeddings_fold = "/embeddings/fold" + + # predictor jobs + predictor_train = "/predictor/train" + predictor_predict = "/predictor/predict" + predictor_crossvalidate = "/predictor/crossvalidate" + predictor_predict_single_site = "/predictor/predict_single_site" + predictor_predict_multi = "/predictor/predict_multi" + predictor_predict_multi_single_site = "/predictor/predict_multi_single_site" + + +class JobStatus(str, Enum): + PENDING = "PENDING" + RUNNING = "RUNNING" + SUCCESS = "SUCCESS" + FAILURE = "FAILURE" + RETRYING = "RETRYING" + CANCELED = "CANCELED" + + def done(self): + return ( + (self is self.SUCCESS) or (self is self.FAILURE) or (self is self.CANCELED) + ) # noqa: E501 + + def cancelled(self): + return self is self.CANCELED + + +class Job(BaseModel): + job_id: str + # new emb service get doesnt have job_type + job_type: JobType + status: JobStatus + created_date: datetime + start_date: datetime | None = None + end_date: datetime | None = None + prerequisite_job_id: str | None = None + progress_message: str | None = None + progress_counter: int | None = None + sequence_length: int | None = None + + @classmethod + def create(cls, obj: "Job | Response | dict", **kwargs) -> Self: + # parse specific child Job from base Job or Response + try: + # try to parse as subclass job + # get dict form + d = ( + obj.json() + if isinstance(obj, Response) + else obj.model_dump() if isinstance(obj, Job) else obj + ) + job_classes = Job.__subclasses__() + job = TypeAdapter(Union[tuple(job_classes)]).validate_python(d | kwargs) # type: ignore + except Exception as e: + raise ValueError(f"Error parsing job from obj: {obj}: {e}") + return job # type: ignore - static checker cannot know runtime type + + # hide extra allowed fields + def __repr_args__(self): + for k, v in self.__dict__.items(): + field = self.model_fields.get(k) + if field and field.repr: + yield k, v + + yield from ( + (k, getattr(self, k)) + for k, v in self.model_computed_fields.items() + if v.repr + ) + + # allows to carry over subclassed job fields when factory creating + model_config = ConfigDict(extra="allow") + + +class BatchJob(BaseModel): + num_records: int | None = None diff --git a/openprotein/schemas/predict.py b/openprotein/schemas/predict.py new file mode 100644 index 0000000..b4626f0 --- /dev/null +++ b/openprotein/schemas/predict.py @@ -0,0 +1,83 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, field_validator + +from .job import Job, JobType + + +class SequenceData(BaseModel): + sequence: str + + +class SequenceDataset(BaseModel): + sequences: list[str] + + +# class _Prediction(BaseModel): +# """Prediction details.""" + +# @root_validator(pre=True) +# def extract_pred(cls, values): +# p = values.pop("properties") +# name = list(p.keys())[0] +# ymu = p[name]["y_mu"] +# yvar = p[name]["y_var"] +# p["name"] = name +# p["y_mu"] = ymu +# p["y_var"] = yvar + +# values.update(p) +# return values + +# model_id: str +# model_name: str +# y_mu: Optional[float] = None +# y_var: Optional[float] = None +# name: Optional[str] + + +class Prediction(BaseModel): + """Prediction details.""" + + model_id: str + model_name: str + properties: dict[str, dict[str, float]] + + class Config: + protected_namespaces = () + + +class PredictJobBase(BaseModel): + # might be none if just fetching + job_id: str | None = None + # doesn't have created date + created_date: datetime | None = None + + +class PredictJob(PredictJobBase, Job): + """Properties about predict job returned via API.""" + + class SequencePrediction(BaseModel): + """Sequence prediction.""" + + sequence: str + predictions: list[Prediction] = [] + + job_type: Literal[JobType.workflow_predict] + result: list[SequencePrediction] | None = None + + +class PredictSingleSiteJob(PredictJobBase, Job): + """Properties about single-site prediction job returned via API.""" + + class MutantPrediction(BaseModel): + """Sequence prediction.""" + + position: int + amino_acid: str + # sequence: str + predictions: list[Prediction] = [] + + result: list[MutantPrediction] | None = None + job_type: Literal[JobType.workflow_predict_single_site] diff --git a/openprotein/schemas/predictor.py b/openprotein/schemas/predictor.py new file mode 100644 index 0000000..c47d183 --- /dev/null +++ b/openprotein/schemas/predictor.py @@ -0,0 +1,83 @@ +from enum import Enum +from typing import Literal + +from pydantic import BaseModel + +from .job import Job, JobStatus, JobType + + +class Kernel(BaseModel): + type: str + multitask: bool = False + + +class Constraints(BaseModel): + sequence_length: int | None = None + + +class FeatureType(str, Enum): + + PLM = "PLM" + SVD = "SVD" + + +class Features(BaseModel): + type: FeatureType + model_id: str | None = None + reduction: str | None = None + + class Config: + protected_namespaces = () + + +class PredictorArgs(BaseModel): + kernel: Kernel + + +class ModelSpec(PredictorArgs, BaseModel): + constraints: Constraints | None = None + features: Features + + +class Dataset(BaseModel): + assay_id: str + properties: list[str] + + +class PredictorMetadata(BaseModel): + id: str + name: str + description: str | None = None + status: JobStatus + model_spec: ModelSpec + training_dataset: Dataset + + def is_done(self): + return self.status.done() + + class Config: + protected_namespaces = () + + +class TrainJob(Job): + job_type: Literal[JobType.predictor_train] + + +class PredictJob(Job): + job_type: Literal[JobType.predictor_predict] + + +class PredictSingleSiteJob(Job): + job_type: Literal[JobType.predictor_predict_single_site] + + +class PredictMultiJob(Job): + job_type: Literal[JobType.predictor_predict_multi] + + +class PredictMultiSingleSiteJob(Job): + job_type: Literal[JobType.predictor_predict_multi_single_site] + + +class CVJob(Job): + job_type: Literal[JobType.predictor_crossvalidate] diff --git a/openprotein/schemas/svd.py b/openprotein/schemas/svd.py new file mode 100644 index 0000000..88e9cae --- /dev/null +++ b/openprotein/schemas/svd.py @@ -0,0 +1,32 @@ +"""Schemas for OpenProtein SVD system.""" + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, Field + +from .job import BatchJob, Job, JobStatus, JobType + + +class SVDMetadata(BaseModel): + id: str + status: JobStatus + created_date: datetime | None = None + model_id: str + n_components: int + reduction: str | None = None + sequence_length: int | None = None + + def is_done(self): + return self.status.done() + + class Config: + protected_namespaces = () + + +class FitJob(Job): + job_type: Literal[JobType.svd_fit] + + +class EmbeddingsJob(Job, BatchJob): + job_type: Literal[JobType.svd_embed] diff --git a/openprotein/schemas/train.py b/openprotein/schemas/train.py new file mode 100644 index 0000000..22b0434 --- /dev/null +++ b/openprotein/schemas/train.py @@ -0,0 +1,34 @@ +from typing import Literal + +from pydantic import BaseModel + +from .job import Job, JobType + + +class CVItem(BaseModel): + row_index: int + sequence: str + measurement_name: str + y: float + y_mu: float + y_var: float + + +class CVJob(Job): + job_type: Literal[JobType.workflow_crossvalidate] + num_rows: int | None = None + page_size: int | None = None + page_offset: int | None = None + result: list[CVItem] | None = None + + +class TrainStep(BaseModel): + step: int + loss: float + tag: str + tags: dict + + +class TrainJob(Job): + job_type: Literal[JobType.workflow_train] + traingraph: list[TrainStep] | None = None diff --git a/pixi.lock b/pixi.lock new file mode 100644 index 0000000..8378130 --- /dev/null +++ b/pixi.lock @@ -0,0 +1,2783 @@ +version: 5 +environments: + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + indexes: + - https://pypi.org/simple + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-h4bc722e_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2024.8.30-hbcca054_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.40-hf3520f5_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.2-h7f98852_5.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-14.1.0-h77fa898_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-14.1.0-h69a702a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-14.1.0-h77fa898_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hd590300_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.46.1-hadc24fc_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.38.1-h0b41bf4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-h4ab18f5_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-he02047a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.3.2-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.10.15-h4a871b0_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8228510_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h4845f30_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h8827d51_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xz-5.2.6-h166bdaf_0.tar.bz2 + - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/da/f1/3702ba2a7470666a62fd81c58a4c40be00670e5006a67f4d626e57f013ae/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7d/4b/a509d346fffede6120cc17610cc500819417ee9c3da7f08d9aaf15cab2a3/numpy-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/89/1b/12521efcbc6058e2673583bb096c2b5046a9df39bd73eca392c1efed24e5/pandas-2.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/df/e4/ba44652d562cbf0bf320e0f3810206149c8a4e99cdbf66da82e97ab53a15/pydantic-2.9.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/11/ac/1e647dc1121c028b691028fa61a4e7477e6aeb5132628fde41dd34c1671f/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/48/5d/acf5905c36149bbaec41ccf7f2b68814647347b72075ac0b1fe3022fdc73/tqdm-4.66.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/65/58/f9c9e6be752e9fcb8b6a0ee9fb87e6e7a1f6bcab2cdc73f02bb7ba91ada0/tzdata-2024.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl + linux-aarch64: + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-2_gnu.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h68df207_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ca-certificates-2024.8.30-hcefe29a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.40-h9fc2d93_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.4.2-h3557bc0_5.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-14.1.0-he277a41_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-ng-14.1.0-he9431aa_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-14.1.0-he277a41_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libnsl-2.0.1-h31becfc_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.46.1-hc4a20ef_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.38.1-hb4cce97_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxcrypt-4.4.36-h31becfc_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.1-h68df207_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.5-hcccb83c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.3.2-h86ecc28_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.10.15-hbf90c55_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.2-h8fc344f_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-h194ca79_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h8827d51_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xz-5.2.6-h9cdd2b7_0.tar.bz2 + - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b8/60/e2f67915a51be59d4539ed189eb0a2b0d292bf79270410746becb32bc2c3/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cd/c4/869f8db87f5c9df86b93ca42036f58911ff162dd091a41e617977ab50d1f/numpy-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/01/c6/d3d2612aea9b9f28e79a30b864835dad8f542dcf474eee09afeee5d15d75/pandas-2.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/df/e4/ba44652d562cbf0bf320e0f3810206149c8a4e99cdbf66da82e97ab53a15/pydantic-2.9.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cc/69/5f945b4416f42ea3f3bc9d2aaec66c76084a6ff4ff27555bf9415ab43189/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/48/5d/acf5905c36149bbaec41ccf7f2b68814647347b72075ac0b1fe3022fdc73/tqdm-4.66.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/65/58/f9c9e6be752e9fcb8b6a0ee9fb87e6e7a1f6bcab2cdc73f02bb7ba91ada0/tzdata-2024.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h99b78c6_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ca-certificates-2024.8.30-hf0a4a13_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.2-h3422bc3_5.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.46.1-hc14010f_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-hfb2fe0b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h7bae524_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.3.2-h8359307_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.10.15-h7d35d02_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h92ec313_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h5083fa2_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h8827d51_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xz-5.2.6-h57fd34a_0.tar.bz2 + - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/46/6a/d5c26c41c49b546860cc1acabdddf48b0b3fb2685f4f5617ac59261b44ae/charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/69/30/f41c9b6dab4e1ec56b40d1daa81ce9f9f8d26da6d02af18768a883676bd5/numpy-2.1.1-cp310-cp310-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/fd/4b/0cd38e68ab690b9df8ef90cba625bf3f93b82d1c719703b8e1b333b2c72d/pandas-2.2.2-cp310-cp310-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/df/e4/ba44652d562cbf0bf320e0f3810206149c8a4e99cdbf66da82e97ab53a15/pydantic-2.9.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/46/76/f68272e4c3a7df8777798282c5e47d508274917f29992d84e1898f8908c7/pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/48/5d/acf5905c36149bbaec41ccf7f2b68814647347b72075ac0b1fe3022fdc73/tqdm-4.66.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/65/58/f9c9e6be752e9fcb8b6a0ee9fb87e6e7a1f6bcab2cdc73f02bb7ba91ada0/tzdata-2024.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl + dev: + channels: + - url: https://conda.anaconda.org/conda-forge/ + indexes: + - https://pypi.org/simple + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-h4bc722e_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2024.8.30-hbcca054_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/editables-0.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.40-hf3520f5_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.2-h7f98852_5.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-14.1.0-h77fa898_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-14.1.0-h69a702a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-14.1.0-h77fa898_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hd590300_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.46.1-hadc24fc_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.38.1-h0b41bf4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-h4ab18f5_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-he02047a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.3.2-hb9d3cd8_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pip-24.2-pyh8b19718_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.10.15-h4a871b0_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8228510_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-75.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h4845f30_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h8827d51_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.44.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xz-5.2.6-h166bdaf_0.tar.bz2 + - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/45/86/4736ac618d82a20d87d2f92ae19441ebc7ac9e7a581d7e58bbe79233b24a/asttokens-2.4.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/da/f1/3702ba2a7470666a62fd81c58a4c40be00670e5006a67f4d626e57f013ae/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/e6/75/49e5bfe642f71f272236b5b2d2691cf915a7283cc0ceda56357b61daa538/comm-0.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/e6/d11966962b1aa515f5586d3907ad019f4b812c04e4546cc19ebf62b5178e/contourpy-1.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/57/0c/c2ec581541923a4d36cee4fd2419c1211c986849fc61097f87aa81fc6ad3/debugpy-1.8.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d5/50/83c593b07763e1161326b3b8c6686f0f4b0f24d5526546bee538c89837d6/decorator-5.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b5/fd/afcd0496feca3276f509df3dbd5dae726fcc756f1a08d9e25abe1733f962/executing-2.1.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e5/12/9a45294a7c4520cc32936edd15df1d5c24af701d2f5f51070a9a43d7664b/fonttools-4.54.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/0c/8b/90e80904fdc24ce33f6fc6f35ebd2232fe731a8528a22008458cf197bc4d/hatchling-1.25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/94/5c/368ae6c01c7628438358e6d337c19b05425727fbb221d2a3c4303c372f42/ipykernel-6.29.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/47/6b/d9fdcdef2eb6a23f391251fde8781c38d42acd82abe84d054cb74f7863b0/ipython-8.18.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/20/9f/bc63f0f0737ad7a60800bfd472a4836661adae21f9c2535f3957b1e54ceb/jedi-0.19.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c9/fb/108ecd1fe961941959ad0ee4e12ee7b8b1477247f30b1fdfd83ceaf017f0/jupyter_core-5.7.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/55/91/0a57ce324caf2ff5403edab71c508dd8f648094b18cfbb4c8cc0fde4a6ac/kiwisolver-1.4.7-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/8d/9d/d06860390f9d154fa884f1740a5456378fb153ff57443c91a4a32bab7092/matplotlib-3.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7d/4b/a509d346fffede6120cc17610cc500819417ee9c3da7f08d9aaf15cab2a3/numpy-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/89/1b/12521efcbc6058e2673583bb096c2b5046a9df39bd73eca392c1efed24e5/pandas-2.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/06/2d/68d58d819798b466e16c01ca934deada2f9165fb3d062f83abbef2f8067e/pandas_stubs-2.2.2.240909-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b5/5b/6651c288b08df3b8c1e2f8c1152201e0b25d240e22ddade0f1e242fc9fa0/pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e8/23/22750c4b768f09386d1c3cc4337953e8936f48a888fa6dddfb669b2c9088/prompt_toolkit-3.0.47-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/19/74/f59e7e0d392bc1070e9a70e2f9190d652487ac115bb16e2eff6b22ad1d24/psutil-6.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/df/e4/ba44652d562cbf0bf320e0f3810206149c8a4e99cdbf66da82e97ab53a15/pydantic-2.9.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/11/ac/1e647dc1121c028b691028fa61a4e7477e6aeb5132628fde41dd34c1671f/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e5/0c/0e3c05b1c87bb6a1c76d281b0f35e78d2d80ac91b5f8f524cebf77f51049/pyparsing-3.1.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/16/29/ca99b4598a9dc7e468b5417eda91f372b595be1e3eec9b7cbe8e5d3584e8/pyzmq-26.2.0-cp310-cp310-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/47/78/b0c2c23880dd1e99e938ad49ccfb011ae353758a2dc5ed7ee59baff684c3/scipy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/22/d4/54f9d12668b58336bd30defe0307e6c61589a3e687b05c366f804b7faaf0/tornado-6.4.1-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/48/5d/acf5905c36149bbaec41ccf7f2b68814647347b72075ac0b1fe3022fdc73/tqdm-4.66.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b6/7a/e0edec9c8905e851d52076bbc41890603e2ba97cf64966bc1498f2244fd2/trove_classifiers-2024.9.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b6/a6/8846372f55c6bb470ff7207e4dc601017e264e5fe7d79a441ece3545b36c/types_pytz-2024.2.0.20240913-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/65/58/f9c9e6be752e9fcb8b6a0ee9fb87e6e7a1f6bcab2cdc73f02bb7ba91ada0/tzdata-2024.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl + linux-aarch64: + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-2_gnu.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h68df207_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ca-certificates-2024.8.30-hcefe29a_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/editables-0.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.40-h9fc2d93_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.4.2-h3557bc0_5.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-14.1.0-he277a41_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-ng-14.1.0-he9431aa_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-14.1.0-he277a41_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libnsl-2.0.1-h31becfc_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.46.1-hc4a20ef_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.38.1-hb4cce97_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxcrypt-4.4.36-h31becfc_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.1-h68df207_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.5-hcccb83c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.3.2-h86ecc28_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pip-24.2-pyh8b19718_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.10.15-hbf90c55_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.2-h8fc344f_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-75.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-h194ca79_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h8827d51_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.44.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xz-5.2.6-h9cdd2b7_0.tar.bz2 + - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/45/86/4736ac618d82a20d87d2f92ae19441ebc7ac9e7a581d7e58bbe79233b24a/asttokens-2.4.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b8/60/e2f67915a51be59d4539ed189eb0a2b0d292bf79270410746becb32bc2c3/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/e6/75/49e5bfe642f71f272236b5b2d2691cf915a7283cc0ceda56357b61daa538/comm-0.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6f/b4/6fffdf213ffccc28483c524b9dad46bb78332851133b36ad354b856ddc7c/contourpy-1.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/02/49/b595c34d7bc690e8d225a6641618a5c111c7e13db5d9e2b756c15ce8f8c6/debugpy-1.8.5-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d5/50/83c593b07763e1161326b3b8c6686f0f4b0f24d5526546bee538c89837d6/decorator-5.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b5/fd/afcd0496feca3276f509df3dbd5dae726fcc756f1a08d9e25abe1733f962/executing-2.1.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/16/6f/b99e0c347732fb003077a2cff38c26f381969b74329aa5597e344d540fe1/fonttools-4.54.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/0c/8b/90e80904fdc24ce33f6fc6f35ebd2232fe731a8528a22008458cf197bc4d/hatchling-1.25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/94/5c/368ae6c01c7628438358e6d337c19b05425727fbb221d2a3c4303c372f42/ipykernel-6.29.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/47/6b/d9fdcdef2eb6a23f391251fde8781c38d42acd82abe84d054cb74f7863b0/ipython-8.18.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/20/9f/bc63f0f0737ad7a60800bfd472a4836661adae21f9c2535f3957b1e54ceb/jedi-0.19.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c9/fb/108ecd1fe961941959ad0ee4e12ee7b8b1477247f30b1fdfd83ceaf017f0/jupyter_core-5.7.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/12/5d/c36140313f2510e20207708adf36ae4919416d697ee0236b0ddfb6fd1050/kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/a8/a0/917f3c6d3a8774a3a1502d9f3dfc1456e07c1fa0c211a23b75a69e154180/matplotlib-3.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cd/c4/869f8db87f5c9df86b93ca42036f58911ff162dd091a41e617977ab50d1f/numpy-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/01/c6/d3d2612aea9b9f28e79a30b864835dad8f542dcf474eee09afeee5d15d75/pandas-2.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/06/2d/68d58d819798b466e16c01ca934deada2f9165fb3d062f83abbef2f8067e/pandas_stubs-2.2.2.240909-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b1/84/9a15cc5726cbbfe7f9f90bfb11f5d028586595907cd093815ca6644932e3/pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e8/23/22750c4b768f09386d1c3cc4337953e8936f48a888fa6dddfb669b2c9088/prompt_toolkit-3.0.47-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cd/5f/60038e277ff0a9cc8f0c9ea3d0c5eb6ee1d2470ea3f9389d776432888e47/psutil-6.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/df/e4/ba44652d562cbf0bf320e0f3810206149c8a4e99cdbf66da82e97ab53a15/pydantic-2.9.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cc/69/5f945b4416f42ea3f3bc9d2aaec66c76084a6ff4ff27555bf9415ab43189/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e5/0c/0e3c05b1c87bb6a1c76d281b0f35e78d2d80ac91b5f8f524cebf77f51049/pyparsing-3.1.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b6/09/b51b6683fde5ca04593a57bbe81788b6b43114d8f8ee4e80afc991e14760/pyzmq-26.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d8/df/cdb6be5274bc694c4c22862ac3438cb04f360ed9df0aecee02ce0b798380/scipy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/cf/786b8f1e6fe1c7c675e79657448178ad65e41c1c9765ef82e7f6f765c4c5/tornado-6.4.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/48/5d/acf5905c36149bbaec41ccf7f2b68814647347b72075ac0b1fe3022fdc73/tqdm-4.66.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b6/7a/e0edec9c8905e851d52076bbc41890603e2ba97cf64966bc1498f2244fd2/trove_classifiers-2024.9.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b6/a6/8846372f55c6bb470ff7207e4dc601017e264e5fe7d79a441ece3545b36c/types_pytz-2024.2.0.20240913-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/65/58/f9c9e6be752e9fcb8b6a0ee9fb87e6e7a1f6bcab2cdc73f02bb7ba91ada0/tzdata-2024.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h99b78c6_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ca-certificates-2024.8.30-hf0a4a13_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/editables-0.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.2-h3422bc3_5.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.46.1-hc14010f_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-hfb2fe0b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h7bae524_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.3.2-h8359307_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pip-24.2-pyh8b19718_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.10.15-h7d35d02_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h92ec313_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-75.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h5083fa2_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h8827d51_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.44.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xz-5.2.6-h57fd34a_0.tar.bz2 + - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/45/86/4736ac618d82a20d87d2f92ae19441ebc7ac9e7a581d7e58bbe79233b24a/asttokens-2.4.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/46/6a/d5c26c41c49b546860cc1acabdddf48b0b3fb2685f4f5617ac59261b44ae/charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/e6/75/49e5bfe642f71f272236b5b2d2691cf915a7283cc0ceda56357b61daa538/comm-0.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/50/d6/c953b400219443535d412fcbbc42e7a5e823291236bc0bb88936e3cc9317/contourpy-1.3.0-cp310-cp310-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/02/49/b595c34d7bc690e8d225a6641618a5c111c7e13db5d9e2b756c15ce8f8c6/debugpy-1.8.5-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d5/50/83c593b07763e1161326b3b8c6686f0f4b0f24d5526546bee538c89837d6/decorator-5.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b5/fd/afcd0496feca3276f509df3dbd5dae726fcc756f1a08d9e25abe1733f962/executing-2.1.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2f/9a/9d899e7ae55b0dd30632e6ca36c0f5fa1205b1b096ec171c9be903673058/fonttools-4.54.1-cp310-cp310-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/0c/8b/90e80904fdc24ce33f6fc6f35ebd2232fe731a8528a22008458cf197bc4d/hatchling-1.25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/94/5c/368ae6c01c7628438358e6d337c19b05425727fbb221d2a3c4303c372f42/ipykernel-6.29.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/47/6b/d9fdcdef2eb6a23f391251fde8781c38d42acd82abe84d054cb74f7863b0/ipython-8.18.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/20/9f/bc63f0f0737ad7a60800bfd472a4836661adae21f9c2535f3957b1e54ceb/jedi-0.19.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c9/fb/108ecd1fe961941959ad0ee4e12ee7b8b1477247f30b1fdfd83ceaf017f0/jupyter_core-5.7.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ef/fa/65de49c85838681fc9cb05de2a68067a683717321e01ddafb5b8024286f0/kiwisolver-1.4.7-cp310-cp310-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/36/98/cbacbd30241369d099f9c13a2b6bc3b7068d85214f5b5795e583ac3d8aba/matplotlib-3.9.2-cp310-cp310-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/69/30/f41c9b6dab4e1ec56b40d1daa81ce9f9f8d26da6d02af18768a883676bd5/numpy-2.1.1-cp310-cp310-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fd/4b/0cd38e68ab690b9df8ef90cba625bf3f93b82d1c719703b8e1b333b2c72d/pandas-2.2.2-cp310-cp310-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/06/2d/68d58d819798b466e16c01ca934deada2f9165fb3d062f83abbef2f8067e/pandas_stubs-2.2.2.240909-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9a/9e/4143b907be8ea0bce215f2ae4f7480027473f8b61fcedfda9d851082a5d2/pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e8/23/22750c4b768f09386d1c3cc4337953e8936f48a888fa6dddfb669b2c9088/prompt_toolkit-3.0.47-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7c/06/63872a64c312a24fb9b4af123ee7007a306617da63ff13bcc1432386ead7/psutil-6.0.0-cp38-abi3-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/df/e4/ba44652d562cbf0bf320e0f3810206149c8a4e99cdbf66da82e97ab53a15/pydantic-2.9.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/46/76/f68272e4c3a7df8777798282c5e47d508274917f29992d84e1898f8908c7/pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e5/0c/0e3c05b1c87bb6a1c76d281b0f35e78d2d80ac91b5f8f524cebf77f51049/pyparsing-3.1.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1f/a8/9837c39aba390eb7d01924ace49d761c8dbe7bc2d6082346d00c8332e431/pyzmq-26.2.0-cp310-cp310-macosx_10_15_universal2.whl + - pypi: https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/43/a5/8d02f9c372790326ad405d94f04d4339482ec082455b9e6e288f7100513b/scipy-1.14.1-cp310-cp310-macosx_12_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/00/d9/c33be3c1a7564f7d42d87a8d186371a75fd142097076767a5c27da941fef/tornado-6.4.1-cp38-abi3-macosx_10_9_universal2.whl + - pypi: https://files.pythonhosted.org/packages/48/5d/acf5905c36149bbaec41ccf7f2b68814647347b72075ac0b1fe3022fdc73/tqdm-4.66.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b6/7a/e0edec9c8905e851d52076bbc41890603e2ba97cf64966bc1498f2244fd2/trove_classifiers-2024.9.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b6/a6/8846372f55c6bb470ff7207e4dc601017e264e5fe7d79a441ece3545b36c/types_pytz-2024.2.0.20240913-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/65/58/f9c9e6be752e9fcb8b6a0ee9fb87e6e7a1f6bcab2cdc73f02bb7ba91ada0/tzdata-2024.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl +packages: +- kind: conda + name: _libgcc_mutex + version: '0.1' + build: conda_forge + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 + sha256: fe51de6107f9edc7aa4f786a70f4a883943bc9d39b3bb7307c04c41410990726 + md5: d7c89558ba9fa0495403155b64376d81 + license: None + purls: [] + size: 2562 + timestamp: 1578324546067 +- kind: conda + name: _openmp_mutex + version: '4.5' + build: 2_gnu + build_number: 16 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 + sha256: fbe2c5e56a653bebb982eda4876a9178aedfc2b545f25d0ce9c4c0b508253d22 + md5: 73aaf86a425cc6e73fcf236a5a46396d + depends: + - _libgcc_mutex 0.1 conda_forge + - libgomp >=7.5.0 + constrains: + - openmp_impl 9999 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 23621 + timestamp: 1650670423406 +- kind: conda + name: _openmp_mutex + version: '4.5' + build: 2_gnu + build_number: 16 + subdir: linux-aarch64 + url: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-2_gnu.tar.bz2 + sha256: 3702bef2f0a4d38bd8288bbe54aace623602a1343c2cfbefd3fa188e015bebf0 + md5: 6168d71addc746e8f2b8d57dfd2edcea + depends: + - libgomp >=7.5.0 + constrains: + - openmp_impl 9999 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 23712 + timestamp: 1650670790230 +- kind: pypi + name: annotated-types + version: 0.7.0 + url: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl + sha256: 1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 + requires_dist: + - typing-extensions>=4.0.0 ; python_full_version < '3.9' + requires_python: '>=3.8' +- kind: pypi + name: appnope + version: 0.1.4 + url: https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl + sha256: 502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c + requires_python: '>=3.6' +- kind: pypi + name: asttokens + version: 2.4.1 + url: https://files.pythonhosted.org/packages/45/86/4736ac618d82a20d87d2f92ae19441ebc7ac9e7a581d7e58bbe79233b24a/asttokens-2.4.1-py2.py3-none-any.whl + sha256: 051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24 + requires_dist: + - six>=1.12.0 + - typing ; python_full_version < '3.5' + - astroid<2,>=1 ; python_full_version < '3.0' and extra == 'astroid' + - astroid<4,>=2 ; python_full_version >= '3.0' and extra == 'astroid' + - pytest ; extra == 'test' + - astroid<2,>=1 ; python_full_version < '3.0' and extra == 'test' + - astroid<4,>=2 ; python_full_version >= '3.0' and extra == 'test' +- kind: conda + name: bzip2 + version: 1.0.8 + build: h4bc722e_7 + build_number: 7 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-h4bc722e_7.conda + sha256: 5ced96500d945fb286c9c838e54fa759aa04a7129c59800f0846b4335cee770d + md5: 62ee74e96c5ebb0af99386de58cf9553 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc-ng >=12 + license: bzip2-1.0.6 + license_family: BSD + purls: [] + size: 252783 + timestamp: 1720974456583 +- kind: conda + name: bzip2 + version: 1.0.8 + build: h68df207_7 + build_number: 7 + subdir: linux-aarch64 + url: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h68df207_7.conda + sha256: 2258b0b33e1cb3a9852d47557984abb6e7ea58e3d7f92706ec1f8e879290c4cb + md5: 56398c28220513b9ea13d7b450acfb20 + depends: + - libgcc-ng >=12 + license: bzip2-1.0.6 + license_family: BSD + purls: [] + size: 189884 + timestamp: 1720974504976 +- kind: conda + name: bzip2 + version: 1.0.8 + build: h99b78c6_7 + build_number: 7 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h99b78c6_7.conda + sha256: adfa71f158cbd872a36394c56c3568e6034aa55c623634b37a4836bd036e6b91 + md5: fc6948412dbbbe9a4c9ddbbcfe0a79ab + depends: + - __osx >=11.0 + license: bzip2-1.0.6 + license_family: BSD + purls: [] + size: 122909 + timestamp: 1720974522888 +- kind: conda + name: ca-certificates + version: 2024.8.30 + build: hbcca054_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2024.8.30-hbcca054_0.conda + sha256: afee721baa6d988e27fef1832f68d6f32ac8cc99cdf6015732224c2841a09cea + md5: c27d1c142233b5bc9ca570c6e2e0c244 + license: ISC + purls: [] + size: 159003 + timestamp: 1725018903918 +- kind: conda + name: ca-certificates + version: 2024.8.30 + build: hcefe29a_0 + subdir: linux-aarch64 + url: https://conda.anaconda.org/conda-forge/linux-aarch64/ca-certificates-2024.8.30-hcefe29a_0.conda + sha256: 2a2d827bee3775a85f0f1b2f2089291475c4416336d1b3a8cbce2964db547af8 + md5: 70e57e8f59d2c98f86b49c69e5074be5 + license: ISC + purls: [] + size: 159106 + timestamp: 1725020043153 +- kind: conda + name: ca-certificates + version: 2024.8.30 + build: hf0a4a13_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/ca-certificates-2024.8.30-hf0a4a13_0.conda + sha256: 2db1733f4b644575dbbdd7994a8f338e6ef937f5ebdb74acd557e9dda0211709 + md5: 40dec13fd8348dbe303e57be74bd3d35 + license: ISC + purls: [] + size: 158482 + timestamp: 1725019034582 +- kind: pypi + name: certifi + version: 2024.8.30 + url: https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl + sha256: 922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8 + requires_python: '>=3.6' +- kind: pypi + name: charset-normalizer + version: 3.3.2 + url: https://files.pythonhosted.org/packages/46/6a/d5c26c41c49b546860cc1acabdddf48b0b3fb2685f4f5617ac59261b44ae/charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl + sha256: 9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03 + requires_python: '>=3.7.0' +- kind: pypi + name: charset-normalizer + version: 3.3.2 + url: https://files.pythonhosted.org/packages/b8/60/e2f67915a51be59d4539ed189eb0a2b0d292bf79270410746becb32bc2c3/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + sha256: 6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d + requires_python: '>=3.7.0' +- kind: pypi + name: charset-normalizer + version: 3.3.2 + url: https://files.pythonhosted.org/packages/da/f1/3702ba2a7470666a62fd81c58a4c40be00670e5006a67f4d626e57f013ae/charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + sha256: 8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5 + requires_python: '>=3.7.0' +- kind: pypi + name: comm + version: 0.2.2 + url: https://files.pythonhosted.org/packages/e6/75/49e5bfe642f71f272236b5b2d2691cf915a7283cc0ceda56357b61daa538/comm-0.2.2-py3-none-any.whl + sha256: e6fb86cb70ff661ee8c9c14e7d36d6de3b4066f1441be4063df9c5009f0a64d3 + requires_dist: + - traitlets>=4 + - pytest ; extra == 'test' + requires_python: '>=3.8' +- kind: pypi + name: contourpy + version: 1.3.0 + url: https://files.pythonhosted.org/packages/50/d6/c953b400219443535d412fcbbc42e7a5e823291236bc0bb88936e3cc9317/contourpy-1.3.0-cp310-cp310-macosx_11_0_arm64.whl + sha256: 76c905ef940a4474a6289c71d53122a4f77766eef23c03cd57016ce19d0f7b42 + requires_dist: + - numpy>=1.23 + - furo ; extra == 'docs' + - sphinx>=7.2 ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - bokeh ; extra == 'bokeh' + - selenium ; extra == 'bokeh' + - contourpy[bokeh,docs] ; extra == 'mypy' + - docutils-stubs ; extra == 'mypy' + - mypy==1.11.1 ; extra == 'mypy' + - types-pillow ; extra == 'mypy' + - contourpy[test-no-images] ; extra == 'test' + - matplotlib ; extra == 'test' + - pillow ; extra == 'test' + - pytest ; extra == 'test-no-images' + - pytest-cov ; extra == 'test-no-images' + - pytest-rerunfailures ; extra == 'test-no-images' + - pytest-xdist ; extra == 'test-no-images' + - wurlitzer ; extra == 'test-no-images' + requires_python: '>=3.9' +- kind: pypi + name: contourpy + version: 1.3.0 + url: https://files.pythonhosted.org/packages/6f/b4/6fffdf213ffccc28483c524b9dad46bb78332851133b36ad354b856ddc7c/contourpy-1.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + sha256: 92f8557cbb07415a4d6fa191f20fd9d2d9eb9c0b61d1b2f52a8926e43c6e9af7 + requires_dist: + - numpy>=1.23 + - furo ; extra == 'docs' + - sphinx>=7.2 ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - bokeh ; extra == 'bokeh' + - selenium ; extra == 'bokeh' + - contourpy[bokeh,docs] ; extra == 'mypy' + - docutils-stubs ; extra == 'mypy' + - mypy==1.11.1 ; extra == 'mypy' + - types-pillow ; extra == 'mypy' + - contourpy[test-no-images] ; extra == 'test' + - matplotlib ; extra == 'test' + - pillow ; extra == 'test' + - pytest ; extra == 'test-no-images' + - pytest-cov ; extra == 'test-no-images' + - pytest-rerunfailures ; extra == 'test-no-images' + - pytest-xdist ; extra == 'test-no-images' + - wurlitzer ; extra == 'test-no-images' + requires_python: '>=3.9' +- kind: pypi + name: contourpy + version: 1.3.0 + url: https://files.pythonhosted.org/packages/99/e6/d11966962b1aa515f5586d3907ad019f4b812c04e4546cc19ebf62b5178e/contourpy-1.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + sha256: 69375194457ad0fad3a839b9e29aa0b0ed53bb54db1bfb6c3ae43d111c31ce41 + requires_dist: + - numpy>=1.23 + - furo ; extra == 'docs' + - sphinx>=7.2 ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - bokeh ; extra == 'bokeh' + - selenium ; extra == 'bokeh' + - contourpy[bokeh,docs] ; extra == 'mypy' + - docutils-stubs ; extra == 'mypy' + - mypy==1.11.1 ; extra == 'mypy' + - types-pillow ; extra == 'mypy' + - contourpy[test-no-images] ; extra == 'test' + - matplotlib ; extra == 'test' + - pillow ; extra == 'test' + - pytest ; extra == 'test-no-images' + - pytest-cov ; extra == 'test-no-images' + - pytest-rerunfailures ; extra == 'test-no-images' + - pytest-xdist ; extra == 'test-no-images' + - wurlitzer ; extra == 'test-no-images' + requires_python: '>=3.9' +- kind: pypi + name: cycler + version: 0.12.1 + url: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl + sha256: 85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30 + requires_dist: + - ipython ; extra == 'docs' + - matplotlib ; extra == 'docs' + - numpydoc ; extra == 'docs' + - sphinx ; extra == 'docs' + - pytest ; extra == 'tests' + - pytest-cov ; extra == 'tests' + - pytest-xdist ; extra == 'tests' + requires_python: '>=3.8' +- kind: pypi + name: debugpy + version: 1.8.5 + url: https://files.pythonhosted.org/packages/02/49/b595c34d7bc690e8d225a6641618a5c111c7e13db5d9e2b756c15ce8f8c6/debugpy-1.8.5-py2.py3-none-any.whl + sha256: 55919dce65b471eff25901acf82d328bbd5b833526b6c1364bd5133754777a44 + requires_python: '>=3.8' +- kind: pypi + name: debugpy + version: 1.8.5 + url: https://files.pythonhosted.org/packages/57/0c/c2ec581541923a4d36cee4fd2419c1211c986849fc61097f87aa81fc6ad3/debugpy-1.8.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + sha256: 4413b7a3ede757dc33a273a17d685ea2b0c09dbd312cc03f5534a0fd4d40750a + requires_python: '>=3.8' +- kind: pypi + name: decorator + version: 5.1.1 + url: https://files.pythonhosted.org/packages/d5/50/83c593b07763e1161326b3b8c6686f0f4b0f24d5526546bee538c89837d6/decorator-5.1.1-py3-none-any.whl + sha256: b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186 + requires_python: '>=3.5' +- kind: conda + name: editables + version: '0.5' + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/editables-0.5-pyhd8ed1ab_0.conda + sha256: de160a7494e7bc72360eea6a29cbddf194d0a79f45ff417a4de20e6858cf79a9 + md5: 9873878e2a069bc358b69e9a29c1ecd5 + depends: + - python >=3.7 + license: MIT + license_family: MIT + purls: + - pkg:pypi/editables?source=hash-mapping + size: 10988 + timestamp: 1705857085102 +- kind: pypi + name: exceptiongroup + version: 1.2.2 + url: https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl + sha256: 3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b + requires_dist: + - pytest>=6 ; extra == 'test' + requires_python: '>=3.7' +- kind: pypi + name: executing + version: 2.1.0 + url: https://files.pythonhosted.org/packages/b5/fd/afcd0496feca3276f509df3dbd5dae726fcc756f1a08d9e25abe1733f962/executing-2.1.0-py2.py3-none-any.whl + sha256: 8d63781349375b5ebccc3142f4b30350c0cd9c79f921cde38be2be4637e98eaf + requires_dist: + - asttokens>=2.1.0 ; extra == 'tests' + - ipython ; extra == 'tests' + - pytest ; extra == 'tests' + - coverage ; extra == 'tests' + - coverage-enable-subprocess ; extra == 'tests' + - littleutils ; extra == 'tests' + - rich ; python_full_version >= '3.11' and extra == 'tests' + requires_python: '>=3.8' +- kind: pypi + name: fonttools + version: 4.54.1 + url: https://files.pythonhosted.org/packages/16/6f/b99e0c347732fb003077a2cff38c26f381969b74329aa5597e344d540fe1/fonttools-4.54.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + sha256: 7965af9b67dd546e52afcf2e38641b5be956d68c425bef2158e95af11d229f10 + requires_dist: + - fs<3,>=2.2.0 ; extra == 'all' + - lxml>=4.0 ; extra == 'all' + - zopfli>=0.1.4 ; extra == 'all' + - lz4>=1.7.4.2 ; extra == 'all' + - pycairo ; extra == 'all' + - matplotlib ; extra == 'all' + - sympy ; extra == 'all' + - skia-pathops>=0.5.0 ; extra == 'all' + - uharfbuzz>=0.23.0 ; extra == 'all' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'all' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'all' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'all' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'all' + - unicodedata2>=15.1.0 ; python_full_version < '3.13' and extra == 'all' + - xattr ; sys_platform == 'darwin' and extra == 'all' + - lz4>=1.7.4.2 ; extra == 'graphite' + - pycairo ; extra == 'interpolatable' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'interpolatable' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'interpolatable' + - lxml>=4.0 ; extra == 'lxml' + - skia-pathops>=0.5.0 ; extra == 'pathops' + - matplotlib ; extra == 'plot' + - uharfbuzz>=0.23.0 ; extra == 'repacker' + - sympy ; extra == 'symfont' + - xattr ; sys_platform == 'darwin' and extra == 'type1' + - fs<3,>=2.2.0 ; extra == 'ufo' + - unicodedata2>=15.1.0 ; python_full_version < '3.13' and extra == 'unicode' + - zopfli>=0.1.4 ; extra == 'woff' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'woff' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'woff' + requires_python: '>=3.8' +- kind: pypi + name: fonttools + version: 4.54.1 + url: https://files.pythonhosted.org/packages/2f/9a/9d899e7ae55b0dd30632e6ca36c0f5fa1205b1b096ec171c9be903673058/fonttools-4.54.1-cp310-cp310-macosx_11_0_arm64.whl + sha256: 41bb0b250c8132b2fcac148e2e9198e62ff06f3cc472065dff839327945c5882 + requires_dist: + - fs<3,>=2.2.0 ; extra == 'all' + - lxml>=4.0 ; extra == 'all' + - zopfli>=0.1.4 ; extra == 'all' + - lz4>=1.7.4.2 ; extra == 'all' + - pycairo ; extra == 'all' + - matplotlib ; extra == 'all' + - sympy ; extra == 'all' + - skia-pathops>=0.5.0 ; extra == 'all' + - uharfbuzz>=0.23.0 ; extra == 'all' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'all' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'all' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'all' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'all' + - unicodedata2>=15.1.0 ; python_full_version < '3.13' and extra == 'all' + - xattr ; sys_platform == 'darwin' and extra == 'all' + - lz4>=1.7.4.2 ; extra == 'graphite' + - pycairo ; extra == 'interpolatable' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'interpolatable' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'interpolatable' + - lxml>=4.0 ; extra == 'lxml' + - skia-pathops>=0.5.0 ; extra == 'pathops' + - matplotlib ; extra == 'plot' + - uharfbuzz>=0.23.0 ; extra == 'repacker' + - sympy ; extra == 'symfont' + - xattr ; sys_platform == 'darwin' and extra == 'type1' + - fs<3,>=2.2.0 ; extra == 'ufo' + - unicodedata2>=15.1.0 ; python_full_version < '3.13' and extra == 'unicode' + - zopfli>=0.1.4 ; extra == 'woff' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'woff' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'woff' + requires_python: '>=3.8' +- kind: pypi + name: fonttools + version: 4.54.1 + url: https://files.pythonhosted.org/packages/e5/12/9a45294a7c4520cc32936edd15df1d5c24af701d2f5f51070a9a43d7664b/fonttools-4.54.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + sha256: 278913a168f90d53378c20c23b80f4e599dca62fbffae4cc620c8eed476b723e + requires_dist: + - fs<3,>=2.2.0 ; extra == 'all' + - lxml>=4.0 ; extra == 'all' + - zopfli>=0.1.4 ; extra == 'all' + - lz4>=1.7.4.2 ; extra == 'all' + - pycairo ; extra == 'all' + - matplotlib ; extra == 'all' + - sympy ; extra == 'all' + - skia-pathops>=0.5.0 ; extra == 'all' + - uharfbuzz>=0.23.0 ; extra == 'all' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'all' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'all' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'all' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'all' + - unicodedata2>=15.1.0 ; python_full_version < '3.13' and extra == 'all' + - xattr ; sys_platform == 'darwin' and extra == 'all' + - lz4>=1.7.4.2 ; extra == 'graphite' + - pycairo ; extra == 'interpolatable' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'interpolatable' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'interpolatable' + - lxml>=4.0 ; extra == 'lxml' + - skia-pathops>=0.5.0 ; extra == 'pathops' + - matplotlib ; extra == 'plot' + - uharfbuzz>=0.23.0 ; extra == 'repacker' + - sympy ; extra == 'symfont' + - xattr ; sys_platform == 'darwin' and extra == 'type1' + - fs<3,>=2.2.0 ; extra == 'ufo' + - unicodedata2>=15.1.0 ; python_full_version < '3.13' and extra == 'unicode' + - zopfli>=0.1.4 ; extra == 'woff' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'woff' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'woff' + requires_python: '>=3.8' +- kind: pypi + name: hatchling + version: 1.25.0 + url: https://files.pythonhosted.org/packages/0c/8b/90e80904fdc24ce33f6fc6f35ebd2232fe731a8528a22008458cf197bc4d/hatchling-1.25.0-py3-none-any.whl + sha256: b47948e45d4d973034584dd4cb39c14b6a70227cf287ab7ec0ad7983408a882c + requires_dist: + - packaging>=23.2 + - pathspec>=0.10.1 + - pluggy>=1.0.0 + - tomli>=1.2.2 ; python_full_version < '3.11' + - trove-classifiers + requires_python: '>=3.8' +- kind: pypi + name: idna + version: '3.10' + url: https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl + sha256: 946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 + requires_dist: + - ruff>=0.6.2 ; extra == 'all' + - mypy>=1.11.2 ; extra == 'all' + - pytest>=8.3.2 ; extra == 'all' + - flake8>=7.1.1 ; extra == 'all' + requires_python: '>=3.6' +- kind: pypi + name: iniconfig + version: 2.0.0 + url: https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl + sha256: b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 + requires_python: '>=3.7' +- kind: pypi + name: ipykernel + version: 6.29.5 + url: https://files.pythonhosted.org/packages/94/5c/368ae6c01c7628438358e6d337c19b05425727fbb221d2a3c4303c372f42/ipykernel-6.29.5-py3-none-any.whl + sha256: afdb66ba5aa354b09b91379bac28ae4afebbb30e8b39510c9690afb7a10421b5 + requires_dist: + - appnope ; platform_system == 'Darwin' + - comm>=0.1.1 + - debugpy>=1.6.5 + - ipython>=7.23.1 + - jupyter-client>=6.1.12 + - jupyter-core!=5.0.*,>=4.12 + - matplotlib-inline>=0.1 + - nest-asyncio + - packaging + - psutil + - pyzmq>=24 + - tornado>=6.1 + - traitlets>=5.4.0 + - coverage[toml] ; extra == 'cov' + - curio ; extra == 'cov' + - matplotlib ; extra == 'cov' + - pytest-cov ; extra == 'cov' + - trio ; extra == 'cov' + - myst-parser ; extra == 'docs' + - pydata-sphinx-theme ; extra == 'docs' + - sphinx ; extra == 'docs' + - sphinx-autodoc-typehints ; extra == 'docs' + - sphinxcontrib-github-alt ; extra == 'docs' + - sphinxcontrib-spelling ; extra == 'docs' + - trio ; extra == 'docs' + - pyqt5 ; extra == 'pyqt5' + - pyside6 ; extra == 'pyside6' + - flaky ; extra == 'test' + - ipyparallel ; extra == 'test' + - pre-commit ; extra == 'test' + - pytest-asyncio>=0.23.5 ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-timeout ; extra == 'test' + - pytest>=7.0 ; extra == 'test' + requires_python: '>=3.8' +- kind: pypi + name: ipython + version: 8.18.1 + url: https://files.pythonhosted.org/packages/47/6b/d9fdcdef2eb6a23f391251fde8781c38d42acd82abe84d054cb74f7863b0/ipython-8.18.1-py3-none-any.whl + sha256: e8267419d72d81955ec1177f8a29aaa90ac80ad647499201119e2f05e99aa397 + requires_dist: + - decorator + - jedi>=0.16 + - matplotlib-inline + - prompt-toolkit<3.1.0,>=3.0.41 + - pygments>=2.4.0 + - stack-data + - traitlets>=5 + - typing-extensions ; python_full_version < '3.10' + - exceptiongroup ; python_full_version < '3.11' + - pexpect>4.3 ; sys_platform != 'win32' + - colorama ; sys_platform == 'win32' + - black ; extra == 'all' + - ipykernel ; extra == 'all' + - setuptools>=18.5 ; extra == 'all' + - sphinx>=1.3 ; extra == 'all' + - sphinx-rtd-theme ; extra == 'all' + - docrepr ; extra == 'all' + - matplotlib ; extra == 'all' + - stack-data ; extra == 'all' + - pytest<7 ; extra == 'all' + - typing-extensions ; extra == 'all' + - exceptiongroup ; extra == 'all' + - pytest<7.1 ; extra == 'all' + - pytest-asyncio<0.22 ; extra == 'all' + - testpath ; extra == 'all' + - pickleshare ; extra == 'all' + - nbconvert ; extra == 'all' + - nbformat ; extra == 'all' + - ipywidgets ; extra == 'all' + - notebook ; extra == 'all' + - ipyparallel ; extra == 'all' + - qtconsole ; extra == 'all' + - curio ; extra == 'all' + - matplotlib!=3.2.0 ; extra == 'all' + - numpy>=1.22 ; extra == 'all' + - pandas ; extra == 'all' + - trio ; extra == 'all' + - black ; extra == 'black' + - ipykernel ; extra == 'doc' + - setuptools>=18.5 ; extra == 'doc' + - sphinx>=1.3 ; extra == 'doc' + - sphinx-rtd-theme ; extra == 'doc' + - docrepr ; extra == 'doc' + - matplotlib ; extra == 'doc' + - stack-data ; extra == 'doc' + - pytest<7 ; extra == 'doc' + - typing-extensions ; extra == 'doc' + - exceptiongroup ; extra == 'doc' + - pytest<7.1 ; extra == 'doc' + - pytest-asyncio<0.22 ; extra == 'doc' + - testpath ; extra == 'doc' + - pickleshare ; extra == 'doc' + - ipykernel ; extra == 'kernel' + - nbconvert ; extra == 'nbconvert' + - nbformat ; extra == 'nbformat' + - ipywidgets ; extra == 'notebook' + - notebook ; extra == 'notebook' + - ipyparallel ; extra == 'parallel' + - qtconsole ; extra == 'qtconsole' + - pytest<7.1 ; extra == 'test' + - pytest-asyncio<0.22 ; extra == 'test' + - testpath ; extra == 'test' + - pickleshare ; extra == 'test' + - pytest<7.1 ; extra == 'test-extra' + - pytest-asyncio<0.22 ; extra == 'test-extra' + - testpath ; extra == 'test-extra' + - pickleshare ; extra == 'test-extra' + - curio ; extra == 'test-extra' + - matplotlib!=3.2.0 ; extra == 'test-extra' + - nbformat ; extra == 'test-extra' + - numpy>=1.22 ; extra == 'test-extra' + - pandas ; extra == 'test-extra' + - trio ; extra == 'test-extra' + requires_python: '>=3.9' +- kind: pypi + name: jedi + version: 0.19.1 + url: https://files.pythonhosted.org/packages/20/9f/bc63f0f0737ad7a60800bfd472a4836661adae21f9c2535f3957b1e54ceb/jedi-0.19.1-py2.py3-none-any.whl + sha256: e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0 + requires_dist: + - parso<0.9.0,>=0.8.3 + - jinja2==2.11.3 ; extra == 'docs' + - markupsafe==1.1.1 ; extra == 'docs' + - pygments==2.8.1 ; extra == 'docs' + - alabaster==0.7.12 ; extra == 'docs' + - babel==2.9.1 ; extra == 'docs' + - chardet==4.0.0 ; extra == 'docs' + - commonmark==0.8.1 ; extra == 'docs' + - docutils==0.17.1 ; extra == 'docs' + - future==0.18.2 ; extra == 'docs' + - idna==2.10 ; extra == 'docs' + - imagesize==1.2.0 ; extra == 'docs' + - mock==1.0.1 ; extra == 'docs' + - packaging==20.9 ; extra == 'docs' + - pyparsing==2.4.7 ; extra == 'docs' + - pytz==2021.1 ; extra == 'docs' + - readthedocs-sphinx-ext==2.1.4 ; extra == 'docs' + - recommonmark==0.5.0 ; extra == 'docs' + - requests==2.25.1 ; extra == 'docs' + - six==1.15.0 ; extra == 'docs' + - snowballstemmer==2.1.0 ; extra == 'docs' + - sphinx-rtd-theme==0.4.3 ; extra == 'docs' + - sphinx==1.8.5 ; extra == 'docs' + - sphinxcontrib-serializinghtml==1.1.4 ; extra == 'docs' + - sphinxcontrib-websupport==1.2.4 ; extra == 'docs' + - urllib3==1.26.4 ; extra == 'docs' + - flake8==5.0.4 ; extra == 'qa' + - mypy==0.971 ; extra == 'qa' + - types-setuptools==67.2.0.1 ; extra == 'qa' + - django ; extra == 'testing' + - attrs ; extra == 'testing' + - colorama ; extra == 'testing' + - docopt ; extra == 'testing' + - pytest<7.0.0 ; extra == 'testing' + requires_python: '>=3.6' +- kind: pypi + name: jupyter-client + version: 8.6.3 + url: https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl + sha256: e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f + requires_dist: + - importlib-metadata>=4.8.3 ; python_full_version < '3.10' + - jupyter-core!=5.0.*,>=4.12 + - python-dateutil>=2.8.2 + - pyzmq>=23.0 + - tornado>=6.2 + - traitlets>=5.3 + - ipykernel ; extra == 'docs' + - myst-parser ; extra == 'docs' + - pydata-sphinx-theme ; extra == 'docs' + - sphinx-autodoc-typehints ; extra == 'docs' + - sphinx>=4 ; extra == 'docs' + - sphinxcontrib-github-alt ; extra == 'docs' + - sphinxcontrib-spelling ; extra == 'docs' + - coverage ; extra == 'test' + - ipykernel>=6.14 ; extra == 'test' + - mypy ; extra == 'test' + - paramiko ; sys_platform == 'win32' and extra == 'test' + - pre-commit ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-jupyter[client]>=0.4.1 ; extra == 'test' + - pytest-timeout ; extra == 'test' + - pytest<8.2.0 ; extra == 'test' + requires_python: '>=3.8' +- kind: pypi + name: jupyter-core + version: 5.7.2 + url: https://files.pythonhosted.org/packages/c9/fb/108ecd1fe961941959ad0ee4e12ee7b8b1477247f30b1fdfd83ceaf017f0/jupyter_core-5.7.2-py3-none-any.whl + sha256: 4f7315d2f6b4bcf2e3e7cb6e46772eba760ae459cd1f59d29eb57b0a01bd7409 + requires_dist: + - platformdirs>=2.5 + - pywin32>=300 ; platform_python_implementation != 'PyPy' and sys_platform == 'win32' + - traitlets>=5.3 + - myst-parser ; extra == 'docs' + - pydata-sphinx-theme ; extra == 'docs' + - sphinx-autodoc-typehints ; extra == 'docs' + - sphinxcontrib-github-alt ; extra == 'docs' + - sphinxcontrib-spelling ; extra == 'docs' + - traitlets ; extra == 'docs' + - ipykernel ; extra == 'test' + - pre-commit ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-timeout ; extra == 'test' + - pytest<8 ; extra == 'test' + requires_python: '>=3.8' +- kind: pypi + name: kiwisolver + version: 1.4.7 + url: https://files.pythonhosted.org/packages/12/5d/c36140313f2510e20207708adf36ae4919416d697ee0236b0ddfb6fd1050/kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + sha256: 88a9ca9c710d598fd75ee5de59d5bda2684d9db36a9f50b6125eaea3969c2599 + requires_python: '>=3.8' +- kind: pypi + name: kiwisolver + version: 1.4.7 + url: https://files.pythonhosted.org/packages/55/91/0a57ce324caf2ff5403edab71c508dd8f648094b18cfbb4c8cc0fde4a6ac/kiwisolver-1.4.7-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl + sha256: 88f17c5ffa8e9462fb79f62746428dd57b46eb931698e42e990ad63103f35e6c + requires_python: '>=3.8' +- kind: pypi + name: kiwisolver + version: 1.4.7 + url: https://files.pythonhosted.org/packages/ef/fa/65de49c85838681fc9cb05de2a68067a683717321e01ddafb5b8024286f0/kiwisolver-1.4.7-cp310-cp310-macosx_11_0_arm64.whl + sha256: aa0abdf853e09aff551db11fce173e2177d00786c688203f52c87ad7fcd91ef9 + requires_python: '>=3.8' +- kind: conda + name: ld_impl_linux-64 + version: '2.40' + build: hf3520f5_7 + build_number: 7 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.40-hf3520f5_7.conda + sha256: 764b6950aceaaad0c67ef925417594dd14cd2e22fff864aeef455ac259263d15 + md5: b80f2f396ca2c28b8c14c437a4ed1e74 + constrains: + - binutils_impl_linux-64 2.40 + license: GPL-3.0-only + license_family: GPL + purls: [] + size: 707602 + timestamp: 1718625640445 +- kind: conda + name: ld_impl_linux-aarch64 + version: '2.40' + build: h9fc2d93_7 + build_number: 7 + subdir: linux-aarch64 + url: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.40-h9fc2d93_7.conda + sha256: 4a6c0bd77e125da8472bd73bba7cd4169a3ce4699b00a3893026ae8664b2387d + md5: 1b0feef706f4d03eff0b76626ead64fc + constrains: + - binutils_impl_linux-aarch64 2.40 + license: GPL-3.0-only + license_family: GPL + purls: [] + size: 735885 + timestamp: 1718625653417 +- kind: conda + name: libffi + version: 3.4.2 + build: h3422bc3_5 + build_number: 5 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.2-h3422bc3_5.tar.bz2 + sha256: 41b3d13efb775e340e4dba549ab5c029611ea6918703096b2eaa9c015c0750ca + md5: 086914b672be056eb70fd4285b6783b6 + license: MIT + license_family: MIT + purls: [] + size: 39020 + timestamp: 1636488587153 +- kind: conda + name: libffi + version: 3.4.2 + build: h3557bc0_5 + build_number: 5 + subdir: linux-aarch64 + url: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.4.2-h3557bc0_5.tar.bz2 + sha256: 7e9258a102480757fe3faeb225a3ca04dffd10fecd2a958c65cdb4cdf75f2c3c + md5: dddd85f4d52121fab0a8b099c5e06501 + depends: + - libgcc-ng >=9.4.0 + license: MIT + license_family: MIT + purls: [] + size: 59450 + timestamp: 1636488255090 +- kind: conda + name: libffi + version: 3.4.2 + build: h7f98852_5 + build_number: 5 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.2-h7f98852_5.tar.bz2 + sha256: ab6e9856c21709b7b517e940ae7028ae0737546122f83c2aa5d692860c3b149e + md5: d645c6d2ac96843a2bfaccd2d62b3ac3 + depends: + - libgcc-ng >=9.4.0 + license: MIT + license_family: MIT + purls: [] + size: 58292 + timestamp: 1636488182923 +- kind: conda + name: libgcc + version: 14.1.0 + build: h77fa898_1 + build_number: 1 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libgcc-14.1.0-h77fa898_1.conda + sha256: 10fa74b69266a2be7b96db881e18fa62cfa03082b65231e8d652e897c4b335a3 + md5: 002ef4463dd1e2b44a94a4ace468f5d2 + depends: + - _libgcc_mutex 0.1 conda_forge + - _openmp_mutex >=4.5 + constrains: + - libgomp 14.1.0 h77fa898_1 + - libgcc-ng ==14.1.0=*_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + size: 846380 + timestamp: 1724801836552 +- kind: conda + name: libgcc + version: 14.1.0 + build: he277a41_1 + build_number: 1 + subdir: linux-aarch64 + url: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-14.1.0-he277a41_1.conda + sha256: 0affee19a50081827a9b7d5a43a1d241d295209342f5c6b8d1da21e950547680 + md5: 2cb475709e327bb76f74645784582e6a + depends: + - _openmp_mutex >=4.5 + constrains: + - libgcc-ng ==14.1.0=*_1 + - libgomp 14.1.0 he277a41_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + size: 533503 + timestamp: 1724802540921 +- kind: conda + name: libgcc-ng + version: 14.1.0 + build: h69a702a_1 + build_number: 1 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-14.1.0-h69a702a_1.conda + sha256: b91f7021e14c3d5c840fbf0dc75370d6e1f7c7ff4482220940eaafb9c64613b7 + md5: 1efc0ad219877a73ef977af7dbb51f17 + depends: + - libgcc 14.1.0 h77fa898_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + size: 52170 + timestamp: 1724801842101 +- kind: conda + name: libgcc-ng + version: 14.1.0 + build: he9431aa_1 + build_number: 1 + subdir: linux-aarch64 + url: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-ng-14.1.0-he9431aa_1.conda + sha256: 44e76a6c1fad613d92035c69e475ccb7da2f554b2fdfabceff8dc4bc570f3622 + md5: 842a1a0cf6f995091734a723e5d291ef + depends: + - libgcc 14.1.0 he277a41_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + size: 52203 + timestamp: 1724802545317 +- kind: conda + name: libgomp + version: 14.1.0 + build: h77fa898_1 + build_number: 1 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libgomp-14.1.0-h77fa898_1.conda + sha256: c96724c8ae4ee61af7674c5d9e5a3fbcf6cd887a40ad5a52c99aa36f1d4f9680 + md5: 23c255b008c4f2ae008f81edcabaca89 + depends: + - _libgcc_mutex 0.1 conda_forge + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + size: 460218 + timestamp: 1724801743478 +- kind: conda + name: libgomp + version: 14.1.0 + build: he277a41_1 + build_number: 1 + subdir: linux-aarch64 + url: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-14.1.0-he277a41_1.conda + sha256: a257997cc35b97a58b4b3be1b108791619736d90af8d30dab717d0f0dd835ab5 + md5: 59d463d51eda114031e52667843f9665 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + size: 461429 + timestamp: 1724802428910 +- kind: conda + name: libnsl + version: 2.0.1 + build: h31becfc_0 + subdir: linux-aarch64 + url: https://conda.anaconda.org/conda-forge/linux-aarch64/libnsl-2.0.1-h31becfc_0.conda + sha256: fd18c2b75d7411096428d36a70b36b1a17e31f7b8956b6905d145792d49e97f8 + md5: c14f32510f694e3185704d89967ec422 + depends: + - libgcc-ng >=12 + license: LGPL-2.1-only + license_family: GPL + purls: [] + size: 34501 + timestamp: 1697358973269 +- kind: conda + name: libnsl + version: 2.0.1 + build: hd590300_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hd590300_0.conda + sha256: 26d77a3bb4dceeedc2a41bd688564fe71bf2d149fdcf117049970bc02ff1add6 + md5: 30fd6e37fe21f86f4bd26d6ee73eeec7 + depends: + - libgcc-ng >=12 + license: LGPL-2.1-only + license_family: GPL + purls: [] + size: 33408 + timestamp: 1697359010159 +- kind: conda + name: libsqlite + version: 3.46.1 + build: hadc24fc_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.46.1-hadc24fc_0.conda + sha256: 9851c049abafed3ee329d6c7c2033407e2fc269d33a75c071110ab52300002b0 + md5: 36f79405ab16bf271edb55b213836dac + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libzlib >=1.3.1,<2.0a0 + license: Unlicense + purls: [] + size: 865214 + timestamp: 1725353659783 +- kind: conda + name: libsqlite + version: 3.46.1 + build: hc14010f_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.46.1-hc14010f_0.conda + sha256: 3725f962f490c5d44dae326d5f5b2e3c97f71a6322d914ccc85b5ddc2e50d120 + md5: 58050ec1724e58668d0126a1615553fa + depends: + - __osx >=11.0 + - libzlib >=1.3.1,<2.0a0 + license: Unlicense + purls: [] + size: 829500 + timestamp: 1725353720793 +- kind: conda + name: libsqlite + version: 3.46.1 + build: hc4a20ef_0 + subdir: linux-aarch64 + url: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.46.1-hc4a20ef_0.conda + sha256: b4ee96d292fea6bdfceb34dff5e5f0e4b21a0a3dab0559a21fc4a35dc217764e + md5: cd559337c1bd9545ecbeaad017e7d878 + depends: + - libgcc >=13 + - libzlib >=1.3.1,<2.0a0 + license: Unlicense + purls: [] + size: 1053752 + timestamp: 1725354110633 +- kind: conda + name: libuuid + version: 2.38.1 + build: h0b41bf4_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.38.1-h0b41bf4_0.conda + sha256: 787eb542f055a2b3de553614b25f09eefb0a0931b0c87dbcce6efdfd92f04f18 + md5: 40b61aab5c7ba9ff276c41cfffe6b80b + depends: + - libgcc-ng >=12 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 33601 + timestamp: 1680112270483 +- kind: conda + name: libuuid + version: 2.38.1 + build: hb4cce97_0 + subdir: linux-aarch64 + url: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.38.1-hb4cce97_0.conda + sha256: 616277b0c5f7616c2cdf36f6c316ea3f9aa5bb35f2d4476a349ab58b9b91675f + md5: 000e30b09db0b7c775b21695dff30969 + depends: + - libgcc-ng >=12 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 35720 + timestamp: 1680113474501 +- kind: conda + name: libxcrypt + version: 4.4.36 + build: h31becfc_1 + build_number: 1 + subdir: linux-aarch64 + url: https://conda.anaconda.org/conda-forge/linux-aarch64/libxcrypt-4.4.36-h31becfc_1.conda + sha256: 6b46c397644091b8a26a3048636d10b989b1bf266d4be5e9474bf763f828f41f + md5: b4df5d7d4b63579d081fd3a4cf99740e + depends: + - libgcc-ng >=12 + license: LGPL-2.1-or-later + purls: [] + size: 114269 + timestamp: 1702724369203 +- kind: conda + name: libxcrypt + version: 4.4.36 + build: hd590300_1 + build_number: 1 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + sha256: 6ae68e0b86423ef188196fff6207ed0c8195dd84273cb5623b85aa08033a410c + md5: 5aa797f8787fe7a17d1b0821485b5adc + depends: + - libgcc-ng >=12 + license: LGPL-2.1-or-later + purls: [] + size: 100393 + timestamp: 1702724383534 +- kind: conda + name: libzlib + version: 1.3.1 + build: h4ab18f5_1 + build_number: 1 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-h4ab18f5_1.conda + sha256: adf6096f98b537a11ae3729eaa642b0811478f0ea0402ca67b5108fe2cb0010d + md5: 57d7dc60e9325e3de37ff8dffd18e814 + depends: + - libgcc-ng >=12 + constrains: + - zlib 1.3.1 *_1 + license: Zlib + license_family: Other + purls: [] + size: 61574 + timestamp: 1716874187109 +- kind: conda + name: libzlib + version: 1.3.1 + build: h68df207_1 + build_number: 1 + subdir: linux-aarch64 + url: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.1-h68df207_1.conda + sha256: 0d6dfd1e36e10c205ff1fdcf42d42289ff0f50be7a4eaa7b34f086a5e22a0734 + md5: b13fb82f88902e34dd0638cd7d378c21 + depends: + - libgcc-ng >=12 + constrains: + - zlib 1.3.1 *_1 + license: Zlib + license_family: Other + purls: [] + size: 67199 + timestamp: 1716874136348 +- kind: conda + name: libzlib + version: 1.3.1 + build: hfb2fe0b_1 + build_number: 1 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-hfb2fe0b_1.conda + sha256: c34365dd37b0eab27b9693af32a1f7f284955517c2cc91f1b88a7ef4738ff03e + md5: 636077128927cf79fd933276dc3aed47 + depends: + - __osx >=11.0 + constrains: + - zlib 1.3.1 *_1 + license: Zlib + license_family: Other + purls: [] + size: 46921 + timestamp: 1716874262512 +- kind: pypi + name: matplotlib + version: 3.9.2 + url: https://files.pythonhosted.org/packages/36/98/cbacbd30241369d099f9c13a2b6bc3b7068d85214f5b5795e583ac3d8aba/matplotlib-3.9.2-cp310-cp310-macosx_11_0_arm64.whl + sha256: c375cc72229614632c87355366bdf2570c2dac01ac66b8ad048d2dabadf2d0d4 + requires_dist: + - contourpy>=1.0.1 + - cycler>=0.10 + - fonttools>=4.22.0 + - kiwisolver>=1.3.1 + - numpy>=1.23 + - packaging>=20.0 + - pillow>=8 + - pyparsing>=2.3.1 + - python-dateutil>=2.7 + - importlib-resources>=3.2.0 ; python_full_version < '3.10' + - meson-python>=0.13.1 ; extra == 'dev' + - numpy>=1.25 ; extra == 'dev' + - pybind11>=2.6 ; extra == 'dev' + - setuptools-scm>=7 ; extra == 'dev' + - setuptools>=64 ; extra == 'dev' + requires_python: '>=3.9' +- kind: pypi + name: matplotlib + version: 3.9.2 + url: https://files.pythonhosted.org/packages/8d/9d/d06860390f9d154fa884f1740a5456378fb153ff57443c91a4a32bab7092/matplotlib-3.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + sha256: ab68d50c06938ef28681073327795c5db99bb4666214d2d5f880ed11aeaded66 + requires_dist: + - contourpy>=1.0.1 + - cycler>=0.10 + - fonttools>=4.22.0 + - kiwisolver>=1.3.1 + - numpy>=1.23 + - packaging>=20.0 + - pillow>=8 + - pyparsing>=2.3.1 + - python-dateutil>=2.7 + - importlib-resources>=3.2.0 ; python_full_version < '3.10' + - meson-python>=0.13.1 ; extra == 'dev' + - numpy>=1.25 ; extra == 'dev' + - pybind11>=2.6 ; extra == 'dev' + - setuptools-scm>=7 ; extra == 'dev' + - setuptools>=64 ; extra == 'dev' + requires_python: '>=3.9' +- kind: pypi + name: matplotlib + version: 3.9.2 + url: https://files.pythonhosted.org/packages/a8/a0/917f3c6d3a8774a3a1502d9f3dfc1456e07c1fa0c211a23b75a69e154180/matplotlib-3.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + sha256: 1d94ff717eb2bd0b58fe66380bd8b14ac35f48a98e7c6765117fe67fb7684e64 + requires_dist: + - contourpy>=1.0.1 + - cycler>=0.10 + - fonttools>=4.22.0 + - kiwisolver>=1.3.1 + - numpy>=1.23 + - packaging>=20.0 + - pillow>=8 + - pyparsing>=2.3.1 + - python-dateutil>=2.7 + - importlib-resources>=3.2.0 ; python_full_version < '3.10' + - meson-python>=0.13.1 ; extra == 'dev' + - numpy>=1.25 ; extra == 'dev' + - pybind11>=2.6 ; extra == 'dev' + - setuptools-scm>=7 ; extra == 'dev' + - setuptools>=64 ; extra == 'dev' + requires_python: '>=3.9' +- kind: pypi + name: matplotlib-inline + version: 0.1.7 + url: https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl + sha256: df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca + requires_dist: + - traitlets + requires_python: '>=3.8' +- kind: conda + name: ncurses + version: '6.5' + build: h7bae524_1 + build_number: 1 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h7bae524_1.conda + sha256: 27d0b9ff78ad46e1f3a6c96c479ab44beda5f96def88e2fe626e0a49429d8afc + md5: cb2b0ea909b97b3d70cd3921d1445e1a + depends: + - __osx >=11.0 + license: X11 AND BSD-3-Clause + purls: [] + size: 802321 + timestamp: 1724658775723 +- kind: conda + name: ncurses + version: '6.5' + build: hcccb83c_1 + build_number: 1 + subdir: linux-aarch64 + url: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.5-hcccb83c_1.conda + sha256: acad4cf1f57b12ee1e42995e6fac646fa06aa026529f05eb8c07eb0a84a47a84 + md5: 91d49c85cacd92caa40cf375ef72a25d + depends: + - libgcc-ng >=12 + license: X11 AND BSD-3-Clause + purls: [] + size: 924472 + timestamp: 1724658573518 +- kind: conda + name: ncurses + version: '6.5' + build: he02047a_1 + build_number: 1 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-he02047a_1.conda + sha256: 6a1d5d8634c1a07913f1c525db6455918cbc589d745fac46d9d6e30340c8731a + md5: 70caf8bb6cf39a0b6b7efc885f51c0fe + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc-ng >=12 + license: X11 AND BSD-3-Clause + purls: [] + size: 889086 + timestamp: 1724658547447 +- kind: pypi + name: nest-asyncio + version: 1.6.0 + url: https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl + sha256: 87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c + requires_python: '>=3.5' +- kind: pypi + name: numpy + version: 2.1.1 + url: https://files.pythonhosted.org/packages/69/30/f41c9b6dab4e1ec56b40d1daa81ce9f9f8d26da6d02af18768a883676bd5/numpy-2.1.1-cp310-cp310-macosx_11_0_arm64.whl + sha256: 7dd86dfaf7c900c0bbdcb8b16e2f6ddf1eb1fe39c6c8cca6e94844ed3152a8fd + requires_python: '>=3.10' +- kind: pypi + name: numpy + version: 2.1.1 + url: https://files.pythonhosted.org/packages/7d/4b/a509d346fffede6120cc17610cc500819417ee9c3da7f08d9aaf15cab2a3/numpy-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + sha256: 913cc1d311060b1d409e609947fa1b9753701dac96e6581b58afc36b7ee35af6 + requires_python: '>=3.10' +- kind: pypi + name: numpy + version: 2.1.1 + url: https://files.pythonhosted.org/packages/cd/c4/869f8db87f5c9df86b93ca42036f58911ff162dd091a41e617977ab50d1f/numpy-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + sha256: 13ce49a34c44b6de5241f0b38b07e44c1b2dcacd9e36c30f9c2fcb1bb5135db7 + requires_python: '>=3.10' +- kind: conda + name: openssl + version: 3.3.2 + build: h8359307_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.3.2-h8359307_0.conda + sha256: 940fa01c4dc6152158fe8943e05e55a1544cab639df0994e3b35937839e4f4d1 + md5: 1773ebccdc13ec603356e8ff1db9e958 + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + purls: [] + size: 2882450 + timestamp: 1725410638874 +- kind: conda + name: openssl + version: 3.3.2 + build: h86ecc28_0 + subdir: linux-aarch64 + url: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.3.2-h86ecc28_0.conda + sha256: 4669d26dbf81e4d72093d8260f55d19d57204d82b1d9440be83d11d313b5990c + md5: 9e1e477b3f8ee3789297883faffa708b + depends: + - ca-certificates + - libgcc >=13 + license: Apache-2.0 + license_family: Apache + purls: [] + size: 3428083 + timestamp: 1725412266679 +- kind: conda + name: openssl + version: 3.3.2 + build: hb9d3cd8_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.3.2-hb9d3cd8_0.conda + sha256: cee91036686419f6dd6086902acf7142b4916e1c4ba042e9ca23e151da012b6d + md5: 4d638782050ab6faa27275bed57e9b4e + depends: + - __glibc >=2.17,<3.0.a0 + - ca-certificates + - libgcc >=13 + license: Apache-2.0 + license_family: Apache + purls: [] + size: 2891789 + timestamp: 1725410790053 +- kind: pypi + name: packaging + version: '24.1' + url: https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl + sha256: 5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124 + requires_python: '>=3.8' +- kind: pypi + name: pandas + version: 2.2.2 + url: https://files.pythonhosted.org/packages/01/c6/d3d2612aea9b9f28e79a30b864835dad8f542dcf474eee09afeee5d15d75/pandas-2.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + sha256: 4abfe0be0d7221be4f12552995e58723c7422c80a659da13ca382697de830c08 + requires_dist: + - numpy>=1.22.4 ; python_full_version < '3.11' + - numpy>=1.23.2 ; python_full_version == '3.11.*' + - numpy>=1.26.0 ; python_full_version >= '3.12' + - python-dateutil>=2.8.2 + - pytz>=2020.1 + - tzdata>=2022.7 + - hypothesis>=6.46.1 ; extra == 'test' + - pytest>=7.3.2 ; extra == 'test' + - pytest-xdist>=2.2.0 ; extra == 'test' + - pyarrow>=10.0.1 ; extra == 'pyarrow' + - bottleneck>=1.3.6 ; extra == 'performance' + - numba>=0.56.4 ; extra == 'performance' + - numexpr>=2.8.4 ; extra == 'performance' + - scipy>=1.10.0 ; extra == 'computation' + - xarray>=2022.12.0 ; extra == 'computation' + - fsspec>=2022.11.0 ; extra == 'fss' + - s3fs>=2022.11.0 ; extra == 'aws' + - gcsfs>=2022.11.0 ; extra == 'gcp' + - pandas-gbq>=0.19.0 ; extra == 'gcp' + - odfpy>=1.4.1 ; extra == 'excel' + - openpyxl>=3.1.0 ; extra == 'excel' + - python-calamine>=0.1.7 ; extra == 'excel' + - pyxlsb>=1.0.10 ; extra == 'excel' + - xlrd>=2.0.1 ; extra == 'excel' + - xlsxwriter>=3.0.5 ; extra == 'excel' + - pyarrow>=10.0.1 ; extra == 'parquet' + - pyarrow>=10.0.1 ; extra == 'feather' + - tables>=3.8.0 ; extra == 'hdf5' + - pyreadstat>=1.2.0 ; extra == 'spss' + - sqlalchemy>=2.0.0 ; extra == 'postgresql' + - psycopg2>=2.9.6 ; extra == 'postgresql' + - adbc-driver-postgresql>=0.8.0 ; extra == 'postgresql' + - sqlalchemy>=2.0.0 ; extra == 'mysql' + - pymysql>=1.0.2 ; extra == 'mysql' + - sqlalchemy>=2.0.0 ; extra == 'sql-other' + - adbc-driver-postgresql>=0.8.0 ; extra == 'sql-other' + - adbc-driver-sqlite>=0.8.0 ; extra == 'sql-other' + - beautifulsoup4>=4.11.2 ; extra == 'html' + - html5lib>=1.1 ; extra == 'html' + - lxml>=4.9.2 ; extra == 'html' + - lxml>=4.9.2 ; extra == 'xml' + - matplotlib>=3.6.3 ; extra == 'plot' + - jinja2>=3.1.2 ; extra == 'output-formatting' + - tabulate>=0.9.0 ; extra == 'output-formatting' + - pyqt5>=5.15.9 ; extra == 'clipboard' + - qtpy>=2.3.0 ; extra == 'clipboard' + - zstandard>=0.19.0 ; extra == 'compression' + - dataframe-api-compat>=0.1.7 ; extra == 'consortium-standard' + - adbc-driver-postgresql>=0.8.0 ; extra == 'all' + - adbc-driver-sqlite>=0.8.0 ; extra == 'all' + - beautifulsoup4>=4.11.2 ; extra == 'all' + - bottleneck>=1.3.6 ; extra == 'all' + - dataframe-api-compat>=0.1.7 ; extra == 'all' + - fastparquet>=2022.12.0 ; extra == 'all' + - fsspec>=2022.11.0 ; extra == 'all' + - gcsfs>=2022.11.0 ; extra == 'all' + - html5lib>=1.1 ; extra == 'all' + - hypothesis>=6.46.1 ; extra == 'all' + - jinja2>=3.1.2 ; extra == 'all' + - lxml>=4.9.2 ; extra == 'all' + - matplotlib>=3.6.3 ; extra == 'all' + - numba>=0.56.4 ; extra == 'all' + - numexpr>=2.8.4 ; extra == 'all' + - odfpy>=1.4.1 ; extra == 'all' + - openpyxl>=3.1.0 ; extra == 'all' + - pandas-gbq>=0.19.0 ; extra == 'all' + - psycopg2>=2.9.6 ; extra == 'all' + - pyarrow>=10.0.1 ; extra == 'all' + - pymysql>=1.0.2 ; extra == 'all' + - pyqt5>=5.15.9 ; extra == 'all' + - pyreadstat>=1.2.0 ; extra == 'all' + - pytest>=7.3.2 ; extra == 'all' + - pytest-xdist>=2.2.0 ; extra == 'all' + - python-calamine>=0.1.7 ; extra == 'all' + - pyxlsb>=1.0.10 ; extra == 'all' + - qtpy>=2.3.0 ; extra == 'all' + - scipy>=1.10.0 ; extra == 'all' + - s3fs>=2022.11.0 ; extra == 'all' + - sqlalchemy>=2.0.0 ; extra == 'all' + - tables>=3.8.0 ; extra == 'all' + - tabulate>=0.9.0 ; extra == 'all' + - xarray>=2022.12.0 ; extra == 'all' + - xlrd>=2.0.1 ; extra == 'all' + - xlsxwriter>=3.0.5 ; extra == 'all' + - zstandard>=0.19.0 ; extra == 'all' + requires_python: '>=3.9' +- kind: pypi + name: pandas + version: 2.2.2 + url: https://files.pythonhosted.org/packages/89/1b/12521efcbc6058e2673583bb096c2b5046a9df39bd73eca392c1efed24e5/pandas-2.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + sha256: 8635c16bf3d99040fdf3ca3db669a7250ddf49c55dc4aa8fe0ae0fa8d6dcc1f0 + requires_dist: + - numpy>=1.22.4 ; python_full_version < '3.11' + - numpy>=1.23.2 ; python_full_version == '3.11.*' + - numpy>=1.26.0 ; python_full_version >= '3.12' + - python-dateutil>=2.8.2 + - pytz>=2020.1 + - tzdata>=2022.7 + - hypothesis>=6.46.1 ; extra == 'test' + - pytest>=7.3.2 ; extra == 'test' + - pytest-xdist>=2.2.0 ; extra == 'test' + - pyarrow>=10.0.1 ; extra == 'pyarrow' + - bottleneck>=1.3.6 ; extra == 'performance' + - numba>=0.56.4 ; extra == 'performance' + - numexpr>=2.8.4 ; extra == 'performance' + - scipy>=1.10.0 ; extra == 'computation' + - xarray>=2022.12.0 ; extra == 'computation' + - fsspec>=2022.11.0 ; extra == 'fss' + - s3fs>=2022.11.0 ; extra == 'aws' + - gcsfs>=2022.11.0 ; extra == 'gcp' + - pandas-gbq>=0.19.0 ; extra == 'gcp' + - odfpy>=1.4.1 ; extra == 'excel' + - openpyxl>=3.1.0 ; extra == 'excel' + - python-calamine>=0.1.7 ; extra == 'excel' + - pyxlsb>=1.0.10 ; extra == 'excel' + - xlrd>=2.0.1 ; extra == 'excel' + - xlsxwriter>=3.0.5 ; extra == 'excel' + - pyarrow>=10.0.1 ; extra == 'parquet' + - pyarrow>=10.0.1 ; extra == 'feather' + - tables>=3.8.0 ; extra == 'hdf5' + - pyreadstat>=1.2.0 ; extra == 'spss' + - sqlalchemy>=2.0.0 ; extra == 'postgresql' + - psycopg2>=2.9.6 ; extra == 'postgresql' + - adbc-driver-postgresql>=0.8.0 ; extra == 'postgresql' + - sqlalchemy>=2.0.0 ; extra == 'mysql' + - pymysql>=1.0.2 ; extra == 'mysql' + - sqlalchemy>=2.0.0 ; extra == 'sql-other' + - adbc-driver-postgresql>=0.8.0 ; extra == 'sql-other' + - adbc-driver-sqlite>=0.8.0 ; extra == 'sql-other' + - beautifulsoup4>=4.11.2 ; extra == 'html' + - html5lib>=1.1 ; extra == 'html' + - lxml>=4.9.2 ; extra == 'html' + - lxml>=4.9.2 ; extra == 'xml' + - matplotlib>=3.6.3 ; extra == 'plot' + - jinja2>=3.1.2 ; extra == 'output-formatting' + - tabulate>=0.9.0 ; extra == 'output-formatting' + - pyqt5>=5.15.9 ; extra == 'clipboard' + - qtpy>=2.3.0 ; extra == 'clipboard' + - zstandard>=0.19.0 ; extra == 'compression' + - dataframe-api-compat>=0.1.7 ; extra == 'consortium-standard' + - adbc-driver-postgresql>=0.8.0 ; extra == 'all' + - adbc-driver-sqlite>=0.8.0 ; extra == 'all' + - beautifulsoup4>=4.11.2 ; extra == 'all' + - bottleneck>=1.3.6 ; extra == 'all' + - dataframe-api-compat>=0.1.7 ; extra == 'all' + - fastparquet>=2022.12.0 ; extra == 'all' + - fsspec>=2022.11.0 ; extra == 'all' + - gcsfs>=2022.11.0 ; extra == 'all' + - html5lib>=1.1 ; extra == 'all' + - hypothesis>=6.46.1 ; extra == 'all' + - jinja2>=3.1.2 ; extra == 'all' + - lxml>=4.9.2 ; extra == 'all' + - matplotlib>=3.6.3 ; extra == 'all' + - numba>=0.56.4 ; extra == 'all' + - numexpr>=2.8.4 ; extra == 'all' + - odfpy>=1.4.1 ; extra == 'all' + - openpyxl>=3.1.0 ; extra == 'all' + - pandas-gbq>=0.19.0 ; extra == 'all' + - psycopg2>=2.9.6 ; extra == 'all' + - pyarrow>=10.0.1 ; extra == 'all' + - pymysql>=1.0.2 ; extra == 'all' + - pyqt5>=5.15.9 ; extra == 'all' + - pyreadstat>=1.2.0 ; extra == 'all' + - pytest>=7.3.2 ; extra == 'all' + - pytest-xdist>=2.2.0 ; extra == 'all' + - python-calamine>=0.1.7 ; extra == 'all' + - pyxlsb>=1.0.10 ; extra == 'all' + - qtpy>=2.3.0 ; extra == 'all' + - scipy>=1.10.0 ; extra == 'all' + - s3fs>=2022.11.0 ; extra == 'all' + - sqlalchemy>=2.0.0 ; extra == 'all' + - tables>=3.8.0 ; extra == 'all' + - tabulate>=0.9.0 ; extra == 'all' + - xarray>=2022.12.0 ; extra == 'all' + - xlrd>=2.0.1 ; extra == 'all' + - xlsxwriter>=3.0.5 ; extra == 'all' + - zstandard>=0.19.0 ; extra == 'all' + requires_python: '>=3.9' +- kind: pypi + name: pandas + version: 2.2.2 + url: https://files.pythonhosted.org/packages/fd/4b/0cd38e68ab690b9df8ef90cba625bf3f93b82d1c719703b8e1b333b2c72d/pandas-2.2.2-cp310-cp310-macosx_11_0_arm64.whl + sha256: c7adfc142dac335d8c1e0dcbd37eb8617eac386596eb9e1a1b77791cf2498238 + requires_dist: + - numpy>=1.22.4 ; python_full_version < '3.11' + - numpy>=1.23.2 ; python_full_version == '3.11.*' + - numpy>=1.26.0 ; python_full_version >= '3.12' + - python-dateutil>=2.8.2 + - pytz>=2020.1 + - tzdata>=2022.7 + - hypothesis>=6.46.1 ; extra == 'test' + - pytest>=7.3.2 ; extra == 'test' + - pytest-xdist>=2.2.0 ; extra == 'test' + - pyarrow>=10.0.1 ; extra == 'pyarrow' + - bottleneck>=1.3.6 ; extra == 'performance' + - numba>=0.56.4 ; extra == 'performance' + - numexpr>=2.8.4 ; extra == 'performance' + - scipy>=1.10.0 ; extra == 'computation' + - xarray>=2022.12.0 ; extra == 'computation' + - fsspec>=2022.11.0 ; extra == 'fss' + - s3fs>=2022.11.0 ; extra == 'aws' + - gcsfs>=2022.11.0 ; extra == 'gcp' + - pandas-gbq>=0.19.0 ; extra == 'gcp' + - odfpy>=1.4.1 ; extra == 'excel' + - openpyxl>=3.1.0 ; extra == 'excel' + - python-calamine>=0.1.7 ; extra == 'excel' + - pyxlsb>=1.0.10 ; extra == 'excel' + - xlrd>=2.0.1 ; extra == 'excel' + - xlsxwriter>=3.0.5 ; extra == 'excel' + - pyarrow>=10.0.1 ; extra == 'parquet' + - pyarrow>=10.0.1 ; extra == 'feather' + - tables>=3.8.0 ; extra == 'hdf5' + - pyreadstat>=1.2.0 ; extra == 'spss' + - sqlalchemy>=2.0.0 ; extra == 'postgresql' + - psycopg2>=2.9.6 ; extra == 'postgresql' + - adbc-driver-postgresql>=0.8.0 ; extra == 'postgresql' + - sqlalchemy>=2.0.0 ; extra == 'mysql' + - pymysql>=1.0.2 ; extra == 'mysql' + - sqlalchemy>=2.0.0 ; extra == 'sql-other' + - adbc-driver-postgresql>=0.8.0 ; extra == 'sql-other' + - adbc-driver-sqlite>=0.8.0 ; extra == 'sql-other' + - beautifulsoup4>=4.11.2 ; extra == 'html' + - html5lib>=1.1 ; extra == 'html' + - lxml>=4.9.2 ; extra == 'html' + - lxml>=4.9.2 ; extra == 'xml' + - matplotlib>=3.6.3 ; extra == 'plot' + - jinja2>=3.1.2 ; extra == 'output-formatting' + - tabulate>=0.9.0 ; extra == 'output-formatting' + - pyqt5>=5.15.9 ; extra == 'clipboard' + - qtpy>=2.3.0 ; extra == 'clipboard' + - zstandard>=0.19.0 ; extra == 'compression' + - dataframe-api-compat>=0.1.7 ; extra == 'consortium-standard' + - adbc-driver-postgresql>=0.8.0 ; extra == 'all' + - adbc-driver-sqlite>=0.8.0 ; extra == 'all' + - beautifulsoup4>=4.11.2 ; extra == 'all' + - bottleneck>=1.3.6 ; extra == 'all' + - dataframe-api-compat>=0.1.7 ; extra == 'all' + - fastparquet>=2022.12.0 ; extra == 'all' + - fsspec>=2022.11.0 ; extra == 'all' + - gcsfs>=2022.11.0 ; extra == 'all' + - html5lib>=1.1 ; extra == 'all' + - hypothesis>=6.46.1 ; extra == 'all' + - jinja2>=3.1.2 ; extra == 'all' + - lxml>=4.9.2 ; extra == 'all' + - matplotlib>=3.6.3 ; extra == 'all' + - numba>=0.56.4 ; extra == 'all' + - numexpr>=2.8.4 ; extra == 'all' + - odfpy>=1.4.1 ; extra == 'all' + - openpyxl>=3.1.0 ; extra == 'all' + - pandas-gbq>=0.19.0 ; extra == 'all' + - psycopg2>=2.9.6 ; extra == 'all' + - pyarrow>=10.0.1 ; extra == 'all' + - pymysql>=1.0.2 ; extra == 'all' + - pyqt5>=5.15.9 ; extra == 'all' + - pyreadstat>=1.2.0 ; extra == 'all' + - pytest>=7.3.2 ; extra == 'all' + - pytest-xdist>=2.2.0 ; extra == 'all' + - python-calamine>=0.1.7 ; extra == 'all' + - pyxlsb>=1.0.10 ; extra == 'all' + - qtpy>=2.3.0 ; extra == 'all' + - scipy>=1.10.0 ; extra == 'all' + - s3fs>=2022.11.0 ; extra == 'all' + - sqlalchemy>=2.0.0 ; extra == 'all' + - tables>=3.8.0 ; extra == 'all' + - tabulate>=0.9.0 ; extra == 'all' + - xarray>=2022.12.0 ; extra == 'all' + - xlrd>=2.0.1 ; extra == 'all' + - xlsxwriter>=3.0.5 ; extra == 'all' + - zstandard>=0.19.0 ; extra == 'all' + requires_python: '>=3.9' +- kind: pypi + name: pandas-stubs + version: 2.2.2.240909 + url: https://files.pythonhosted.org/packages/06/2d/68d58d819798b466e16c01ca934deada2f9165fb3d062f83abbef2f8067e/pandas_stubs-2.2.2.240909-py3-none-any.whl + sha256: e230f5fa4065f9417804f4d65cd98f86c002efcc07933e8abcd48c3fad9c30a2 + requires_dist: + - numpy>=1.23.5 + - types-pytz>=2022.1.1 + requires_python: '>=3.10' +- kind: pypi + name: parso + version: 0.8.4 + url: https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl + sha256: a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18 + requires_dist: + - flake8==5.0.4 ; extra == 'qa' + - mypy==0.971 ; extra == 'qa' + - types-setuptools==67.2.0.1 ; extra == 'qa' + - docopt ; extra == 'testing' + - pytest ; extra == 'testing' + requires_python: '>=3.6' +- kind: pypi + name: pathspec + version: 0.12.1 + url: https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl + sha256: a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08 + requires_python: '>=3.8' +- kind: pypi + name: pexpect + version: 4.9.0 + url: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl + sha256: 7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523 + requires_dist: + - ptyprocess>=0.5 +- kind: pypi + name: pillow + version: 10.4.0 + url: https://files.pythonhosted.org/packages/9a/9e/4143b907be8ea0bce215f2ae4f7480027473f8b61fcedfda9d851082a5d2/pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl + sha256: 543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d + requires_dist: + - furo ; extra == 'docs' + - olefile ; extra == 'docs' + - sphinx>=7.3 ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - sphinx-inline-tabs ; extra == 'docs' + - sphinxext-opengraph ; extra == 'docs' + - olefile ; extra == 'fpx' + - olefile ; extra == 'mic' + - check-manifest ; extra == 'tests' + - coverage ; extra == 'tests' + - defusedxml ; extra == 'tests' + - markdown2 ; extra == 'tests' + - olefile ; extra == 'tests' + - packaging ; extra == 'tests' + - pyroma ; extra == 'tests' + - pytest ; extra == 'tests' + - pytest-cov ; extra == 'tests' + - pytest-timeout ; extra == 'tests' + - typing-extensions ; python_full_version < '3.10' and extra == 'typing' + - defusedxml ; extra == 'xmp' + requires_python: '>=3.8' +- kind: pypi + name: pillow + version: 10.4.0 + url: https://files.pythonhosted.org/packages/b1/84/9a15cc5726cbbfe7f9f90bfb11f5d028586595907cd093815ca6644932e3/pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl + sha256: 6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b + requires_dist: + - furo ; extra == 'docs' + - olefile ; extra == 'docs' + - sphinx>=7.3 ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - sphinx-inline-tabs ; extra == 'docs' + - sphinxext-opengraph ; extra == 'docs' + - olefile ; extra == 'fpx' + - olefile ; extra == 'mic' + - check-manifest ; extra == 'tests' + - coverage ; extra == 'tests' + - defusedxml ; extra == 'tests' + - markdown2 ; extra == 'tests' + - olefile ; extra == 'tests' + - packaging ; extra == 'tests' + - pyroma ; extra == 'tests' + - pytest ; extra == 'tests' + - pytest-cov ; extra == 'tests' + - pytest-timeout ; extra == 'tests' + - typing-extensions ; python_full_version < '3.10' and extra == 'typing' + - defusedxml ; extra == 'xmp' + requires_python: '>=3.8' +- kind: pypi + name: pillow + version: 10.4.0 + url: https://files.pythonhosted.org/packages/b5/5b/6651c288b08df3b8c1e2f8c1152201e0b25d240e22ddade0f1e242fc9fa0/pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl + sha256: a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc + requires_dist: + - furo ; extra == 'docs' + - olefile ; extra == 'docs' + - sphinx>=7.3 ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - sphinx-inline-tabs ; extra == 'docs' + - sphinxext-opengraph ; extra == 'docs' + - olefile ; extra == 'fpx' + - olefile ; extra == 'mic' + - check-manifest ; extra == 'tests' + - coverage ; extra == 'tests' + - defusedxml ; extra == 'tests' + - markdown2 ; extra == 'tests' + - olefile ; extra == 'tests' + - packaging ; extra == 'tests' + - pyroma ; extra == 'tests' + - pytest ; extra == 'tests' + - pytest-cov ; extra == 'tests' + - pytest-timeout ; extra == 'tests' + - typing-extensions ; python_full_version < '3.10' and extra == 'typing' + - defusedxml ; extra == 'xmp' + requires_python: '>=3.8' +- kind: conda + name: pip + version: '24.2' + build: pyh8b19718_1 + build_number: 1 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/pip-24.2-pyh8b19718_1.conda + sha256: d820e5358bcb117fa6286e55d4550c60b0332443df62121df839eab2d11c890b + md5: 6c78fbb8ddfd64bcb55b5cbafd2d2c43 + depends: + - python >=3.8,<3.13.0a0 + - setuptools + - wheel + license: MIT + license_family: MIT + purls: + - pkg:pypi/pip?source=hash-mapping + size: 1237976 + timestamp: 1724954490262 +- kind: pypi + name: platformdirs + version: 4.3.6 + url: https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl + sha256: 73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb + requires_dist: + - furo>=2024.8.6 ; extra == 'docs' + - proselint>=0.14 ; extra == 'docs' + - sphinx-autodoc-typehints>=2.4 ; extra == 'docs' + - sphinx>=8.0.2 ; extra == 'docs' + - appdirs==1.4.4 ; extra == 'test' + - covdefaults>=2.3 ; extra == 'test' + - pytest-cov>=5 ; extra == 'test' + - pytest-mock>=3.14 ; extra == 'test' + - pytest>=8.3.2 ; extra == 'test' + - mypy>=1.11.2 ; extra == 'type' + requires_python: '>=3.8' +- kind: pypi + name: pluggy + version: 1.5.0 + url: https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl + sha256: 44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 + requires_dist: + - pre-commit ; extra == 'dev' + - tox ; extra == 'dev' + - pytest ; extra == 'testing' + - pytest-benchmark ; extra == 'testing' + requires_python: '>=3.8' +- kind: pypi + name: prompt-toolkit + version: 3.0.47 + url: https://files.pythonhosted.org/packages/e8/23/22750c4b768f09386d1c3cc4337953e8936f48a888fa6dddfb669b2c9088/prompt_toolkit-3.0.47-py3-none-any.whl + sha256: 0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10 + requires_dist: + - wcwidth + requires_python: '>=3.7.0' +- kind: pypi + name: psutil + version: 6.0.0 + url: https://files.pythonhosted.org/packages/19/74/f59e7e0d392bc1070e9a70e2f9190d652487ac115bb16e2eff6b22ad1d24/psutil-6.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl + sha256: 5fd9a97c8e94059b0ef54a7d4baf13b405011176c3b6ff257c247cae0d560ecd + requires_dist: + - ipaddress ; python_full_version < '3.0' and extra == 'test' + - mock ; python_full_version < '3.0' and extra == 'test' + - enum34 ; python_full_version < '3.5' and extra == 'test' + - pywin32 ; sys_platform == 'win32' and extra == 'test' + - wmi ; sys_platform == 'win32' and extra == 'test' + requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*' +- kind: pypi + name: psutil + version: 6.0.0 + url: https://files.pythonhosted.org/packages/7c/06/63872a64c312a24fb9b4af123ee7007a306617da63ff13bcc1432386ead7/psutil-6.0.0-cp38-abi3-macosx_11_0_arm64.whl + sha256: ffe7fc9b6b36beadc8c322f84e1caff51e8703b88eee1da46d1e3a6ae11b4fd0 + requires_dist: + - ipaddress ; python_full_version < '3.0' and extra == 'test' + - mock ; python_full_version < '3.0' and extra == 'test' + - enum34 ; python_full_version < '3.5' and extra == 'test' + - pywin32 ; sys_platform == 'win32' and extra == 'test' + - wmi ; sys_platform == 'win32' and extra == 'test' + requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*' +- kind: pypi + name: psutil + version: 6.0.0 + url: https://files.pythonhosted.org/packages/cd/5f/60038e277ff0a9cc8f0c9ea3d0c5eb6ee1d2470ea3f9389d776432888e47/psutil-6.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + sha256: e2e8d0054fc88153ca0544f5c4d554d42e33df2e009c4ff42284ac9ebdef4132 + requires_dist: + - ipaddress ; python_full_version < '3.0' and extra == 'test' + - mock ; python_full_version < '3.0' and extra == 'test' + - enum34 ; python_full_version < '3.5' and extra == 'test' + - pywin32 ; sys_platform == 'win32' and extra == 'test' + - wmi ; sys_platform == 'win32' and extra == 'test' + requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*' +- kind: pypi + name: ptyprocess + version: 0.7.0 + url: https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl + sha256: 4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35 +- kind: pypi + name: pure-eval + version: 0.2.3 + url: https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl + sha256: 1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0 + requires_dist: + - pytest ; extra == 'tests' +- kind: pypi + name: pydantic + version: 2.9.2 + url: https://files.pythonhosted.org/packages/df/e4/ba44652d562cbf0bf320e0f3810206149c8a4e99cdbf66da82e97ab53a15/pydantic-2.9.2-py3-none-any.whl + sha256: f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12 + requires_dist: + - annotated-types>=0.6.0 + - pydantic-core==2.23.4 + - typing-extensions>=4.12.2 ; python_full_version >= '3.13' + - typing-extensions>=4.6.1 ; python_full_version < '3.13' + - email-validator>=2.0.0 ; extra == 'email' + - tzdata ; python_full_version >= '3.9' and sys_platform == 'win32' and extra == 'timezone' + requires_python: '>=3.8' +- kind: pypi + name: pydantic-core + version: 2.23.4 + url: https://files.pythonhosted.org/packages/11/ac/1e647dc1121c028b691028fa61a4e7477e6aeb5132628fde41dd34c1671f/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + sha256: 4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2 + requires_dist: + - typing-extensions>=4.6.0,!=4.7.0 + requires_python: '>=3.8' +- kind: pypi + name: pydantic-core + version: 2.23.4 + url: https://files.pythonhosted.org/packages/46/76/f68272e4c3a7df8777798282c5e47d508274917f29992d84e1898f8908c7/pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl + sha256: 4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166 + requires_dist: + - typing-extensions>=4.6.0,!=4.7.0 + requires_python: '>=3.8' +- kind: pypi + name: pydantic-core + version: 2.23.4 + url: https://files.pythonhosted.org/packages/cc/69/5f945b4416f42ea3f3bc9d2aaec66c76084a6ff4ff27555bf9415ab43189/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + sha256: 63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb + requires_dist: + - typing-extensions>=4.6.0,!=4.7.0 + requires_python: '>=3.8' +- kind: pypi + name: pygments + version: 2.18.0 + url: https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl + sha256: b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a + requires_dist: + - colorama>=0.4.6 ; extra == 'windows-terminal' + requires_python: '>=3.8' +- kind: pypi + name: pyparsing + version: 3.1.4 + url: https://files.pythonhosted.org/packages/e5/0c/0e3c05b1c87bb6a1c76d281b0f35e78d2d80ac91b5f8f524cebf77f51049/pyparsing-3.1.4-py3-none-any.whl + sha256: a6a7ee4235a3f944aa1fa2249307708f893fe5717dc603503c6c7969c070fb7c + requires_dist: + - railroad-diagrams ; extra == 'diagrams' + - jinja2 ; extra == 'diagrams' + requires_python: '>=3.6.8' +- kind: pypi + name: pytest + version: 8.3.3 + url: https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl + sha256: a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2 + requires_dist: + - iniconfig + - packaging + - pluggy<2,>=1.5 + - exceptiongroup>=1.0.0rc8 ; python_full_version < '3.11' + - tomli>=1 ; python_full_version < '3.11' + - colorama ; sys_platform == 'win32' + - argcomplete ; extra == 'dev' + - attrs>=19.2 ; extra == 'dev' + - hypothesis>=3.56 ; extra == 'dev' + - mock ; extra == 'dev' + - pygments>=2.7.2 ; extra == 'dev' + - requests ; extra == 'dev' + - setuptools ; extra == 'dev' + - xmlschema ; extra == 'dev' + requires_python: '>=3.8' +- kind: conda + name: python + version: 3.10.15 + build: h4a871b0_0_cpython + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/python-3.10.15-h4a871b0_0_cpython.conda + sha256: 186b4cc1000afb84b8c5a5f4ccdccaf178f940eaf885634cbbe51abba43b5e73 + md5: f5c36ac89c928702bcfd2e2d567048d9 + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 + - libffi >=3.4,<4.0a0 + - libgcc >=13 + - libnsl >=2.0.1,<2.1.0a0 + - libsqlite >=3.46.1,<4.0a0 + - libuuid >=2.38.1,<3.0a0 + - libxcrypt >=4.4.36 + - libzlib >=1.3.1,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.3.2,<4.0a0 + - readline >=8.2,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - xz >=5.2.6,<6.0a0 + constrains: + - python_abi 3.10.* *_cp310 + license: Python-2.0 + purls: [] + size: 25288946 + timestamp: 1726851437185 +- kind: conda + name: python + version: 3.10.15 + build: h7d35d02_0_cpython + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.10.15-h7d35d02_0_cpython.conda + sha256: e6a32f9f68eaf172fbb3945f7b265dbab1d7bba72b9105d2c55c113a50559b4d + md5: f61c19875f1237f09be13e84c5468dd2 + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libffi >=3.4,<4.0a0 + - libsqlite >=3.46.1,<4.0a0 + - libzlib >=1.3.1,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.3.2,<4.0a0 + - readline >=8.2,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - xz >=5.2.6,<6.0a0 + constrains: + - python_abi 3.10.* *_cp310 + license: Python-2.0 + purls: [] + size: 12330805 + timestamp: 1726850364281 +- kind: conda + name: python + version: 3.10.15 + build: hbf90c55_0_cpython + subdir: linux-aarch64 + url: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.10.15-hbf90c55_0_cpython.conda + sha256: 5aea39b716e8a45ce4d7b4047a7031fc37d0cea0884a6bca19ed44822ac55877 + md5: 055db17478151a40734d4e6c7c2e5837 + depends: + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-aarch64 >=2.36.1 + - libffi >=3.4,<4.0a0 + - libgcc >=13 + - libnsl >=2.0.1,<2.1.0a0 + - libsqlite >=3.46.1,<4.0a0 + - libuuid >=2.38.1,<3.0a0 + - libxcrypt >=4.4.36 + - libzlib >=1.3.1,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.3.2,<4.0a0 + - readline >=8.2,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - xz >=5.2.6,<6.0a0 + constrains: + - python_abi 3.10.* *_cp310 + license: Python-2.0 + purls: [] + size: 13024590 + timestamp: 1726850017490 +- kind: pypi + name: python-dateutil + version: 2.9.0.post0 + url: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl + sha256: a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 + requires_dist: + - six>=1.5 + requires_python: '!=3.0.*,!=3.1.*,!=3.2.*,>=2.7' +- kind: pypi + name: pytz + version: '2024.2' + url: https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl + sha256: 31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725 +- kind: pypi + name: pyzmq + version: 26.2.0 + url: https://files.pythonhosted.org/packages/16/29/ca99b4598a9dc7e468b5417eda91f372b595be1e3eec9b7cbe8e5d3584e8/pyzmq-26.2.0-cp310-cp310-manylinux_2_28_x86_64.whl + sha256: a2224fa4a4c2ee872886ed00a571f5e967c85e078e8e8c2530a2fb01b3309b88 + requires_dist: + - cffi ; implementation_name == 'pypy' + requires_python: '>=3.7' +- kind: pypi + name: pyzmq + version: 26.2.0 + url: https://files.pythonhosted.org/packages/1f/a8/9837c39aba390eb7d01924ace49d761c8dbe7bc2d6082346d00c8332e431/pyzmq-26.2.0-cp310-cp310-macosx_10_15_universal2.whl + sha256: ddf33d97d2f52d89f6e6e7ae66ee35a4d9ca6f36eda89c24591b0c40205a3629 + requires_dist: + - cffi ; implementation_name == 'pypy' + requires_python: '>=3.7' +- kind: pypi + name: pyzmq + version: 26.2.0 + url: https://files.pythonhosted.org/packages/b6/09/b51b6683fde5ca04593a57bbe81788b6b43114d8f8ee4e80afc991e14760/pyzmq-26.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + sha256: 89289a5ee32ef6c439086184529ae060c741334b8970a6855ec0b6ad3ff28764 + requires_dist: + - cffi ; implementation_name == 'pypy' + requires_python: '>=3.7' +- kind: conda + name: readline + version: '8.2' + build: h8228510_1 + build_number: 1 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8228510_1.conda + sha256: 5435cf39d039387fbdc977b0a762357ea909a7694d9528ab40f005e9208744d7 + md5: 47d31b792659ce70f470b5c82fdfb7a4 + depends: + - libgcc-ng >=12 + - ncurses >=6.3,<7.0a0 + license: GPL-3.0-only + license_family: GPL + purls: [] + size: 281456 + timestamp: 1679532220005 +- kind: conda + name: readline + version: '8.2' + build: h8fc344f_1 + build_number: 1 + subdir: linux-aarch64 + url: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.2-h8fc344f_1.conda + sha256: 4c99f7417419734e3797d45bc355e61c26520e111893b0d7087a01a7fbfbe3dd + md5: 105eb1e16bf83bfb2eb380a48032b655 + depends: + - libgcc-ng >=12 + - ncurses >=6.3,<7.0a0 + license: GPL-3.0-only + license_family: GPL + purls: [] + size: 294092 + timestamp: 1679532238805 +- kind: conda + name: readline + version: '8.2' + build: h92ec313_1 + build_number: 1 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h92ec313_1.conda + sha256: a1dfa679ac3f6007362386576a704ad2d0d7a02e98f5d0b115f207a2da63e884 + md5: 8cbb776a2f641b943d413b3e19df71f4 + depends: + - ncurses >=6.3,<7.0a0 + license: GPL-3.0-only + license_family: GPL + purls: [] + size: 250351 + timestamp: 1679532511311 +- kind: pypi + name: requests + version: 2.32.3 + url: https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl + sha256: 70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 + requires_dist: + - charset-normalizer<4,>=2 + - idna<4,>=2.5 + - urllib3<3,>=1.21.1 + - certifi>=2017.4.17 + - pysocks!=1.5.7,>=1.5.6 ; extra == 'socks' + - chardet<6,>=3.0.2 ; extra == 'use-chardet-on-py3' + requires_python: '>=3.8' +- kind: pypi + name: scipy + version: 1.14.1 + url: https://files.pythonhosted.org/packages/43/a5/8d02f9c372790326ad405d94f04d4339482ec082455b9e6e288f7100513b/scipy-1.14.1-cp310-cp310-macosx_12_0_arm64.whl + sha256: d0d2821003174de06b69e58cef2316a6622b60ee613121199cb2852a873f8cf3 + requires_dist: + - numpy<2.3,>=1.23.5 + - pytest ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-timeout ; extra == 'test' + - pytest-xdist ; extra == 'test' + - asv ; extra == 'test' + - mpmath ; extra == 'test' + - gmpy2 ; extra == 'test' + - threadpoolctl ; extra == 'test' + - scikit-umfpack ; extra == 'test' + - pooch ; extra == 'test' + - hypothesis>=6.30 ; extra == 'test' + - array-api-strict>=2.0 ; extra == 'test' + - cython ; extra == 'test' + - meson ; extra == 'test' + - ninja ; sys_platform != 'emscripten' and extra == 'test' + - sphinx<=7.3.7,>=5.0.0 ; extra == 'doc' + - pydata-sphinx-theme>=0.15.2 ; extra == 'doc' + - sphinx-design>=0.4.0 ; extra == 'doc' + - matplotlib>=3.5 ; extra == 'doc' + - numpydoc ; extra == 'doc' + - jupytext ; extra == 'doc' + - myst-nb ; extra == 'doc' + - pooch ; extra == 'doc' + - jupyterlite-sphinx>=0.13.1 ; extra == 'doc' + - jupyterlite-pyodide-kernel ; extra == 'doc' + - mypy==1.10.0 ; extra == 'dev' + - typing-extensions ; extra == 'dev' + - types-psutil ; extra == 'dev' + - pycodestyle ; extra == 'dev' + - ruff>=0.0.292 ; extra == 'dev' + - cython-lint>=0.12.2 ; extra == 'dev' + - rich-click ; extra == 'dev' + - doit>=0.36.0 ; extra == 'dev' + - pydevtool ; extra == 'dev' + requires_python: '>=3.10' +- kind: pypi + name: scipy + version: 1.14.1 + url: https://files.pythonhosted.org/packages/47/78/b0c2c23880dd1e99e938ad49ccfb011ae353758a2dc5ed7ee59baff684c3/scipy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + sha256: 8e32dced201274bf96899e6491d9ba3e9a5f6b336708656466ad0522d8528f69 + requires_dist: + - numpy<2.3,>=1.23.5 + - pytest ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-timeout ; extra == 'test' + - pytest-xdist ; extra == 'test' + - asv ; extra == 'test' + - mpmath ; extra == 'test' + - gmpy2 ; extra == 'test' + - threadpoolctl ; extra == 'test' + - scikit-umfpack ; extra == 'test' + - pooch ; extra == 'test' + - hypothesis>=6.30 ; extra == 'test' + - array-api-strict>=2.0 ; extra == 'test' + - cython ; extra == 'test' + - meson ; extra == 'test' + - ninja ; sys_platform != 'emscripten' and extra == 'test' + - sphinx<=7.3.7,>=5.0.0 ; extra == 'doc' + - pydata-sphinx-theme>=0.15.2 ; extra == 'doc' + - sphinx-design>=0.4.0 ; extra == 'doc' + - matplotlib>=3.5 ; extra == 'doc' + - numpydoc ; extra == 'doc' + - jupytext ; extra == 'doc' + - myst-nb ; extra == 'doc' + - pooch ; extra == 'doc' + - jupyterlite-sphinx>=0.13.1 ; extra == 'doc' + - jupyterlite-pyodide-kernel ; extra == 'doc' + - mypy==1.10.0 ; extra == 'dev' + - typing-extensions ; extra == 'dev' + - types-psutil ; extra == 'dev' + - pycodestyle ; extra == 'dev' + - ruff>=0.0.292 ; extra == 'dev' + - cython-lint>=0.12.2 ; extra == 'dev' + - rich-click ; extra == 'dev' + - doit>=0.36.0 ; extra == 'dev' + - pydevtool ; extra == 'dev' + requires_python: '>=3.10' +- kind: pypi + name: scipy + version: 1.14.1 + url: https://files.pythonhosted.org/packages/d8/df/cdb6be5274bc694c4c22862ac3438cb04f360ed9df0aecee02ce0b798380/scipy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + sha256: 2ff0a7e01e422c15739ecd64432743cf7aae2b03f3084288f399affcefe5222d + requires_dist: + - numpy<2.3,>=1.23.5 + - pytest ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-timeout ; extra == 'test' + - pytest-xdist ; extra == 'test' + - asv ; extra == 'test' + - mpmath ; extra == 'test' + - gmpy2 ; extra == 'test' + - threadpoolctl ; extra == 'test' + - scikit-umfpack ; extra == 'test' + - pooch ; extra == 'test' + - hypothesis>=6.30 ; extra == 'test' + - array-api-strict>=2.0 ; extra == 'test' + - cython ; extra == 'test' + - meson ; extra == 'test' + - ninja ; sys_platform != 'emscripten' and extra == 'test' + - sphinx<=7.3.7,>=5.0.0 ; extra == 'doc' + - pydata-sphinx-theme>=0.15.2 ; extra == 'doc' + - sphinx-design>=0.4.0 ; extra == 'doc' + - matplotlib>=3.5 ; extra == 'doc' + - numpydoc ; extra == 'doc' + - jupytext ; extra == 'doc' + - myst-nb ; extra == 'doc' + - pooch ; extra == 'doc' + - jupyterlite-sphinx>=0.13.1 ; extra == 'doc' + - jupyterlite-pyodide-kernel ; extra == 'doc' + - mypy==1.10.0 ; extra == 'dev' + - typing-extensions ; extra == 'dev' + - types-psutil ; extra == 'dev' + - pycodestyle ; extra == 'dev' + - ruff>=0.0.292 ; extra == 'dev' + - cython-lint>=0.12.2 ; extra == 'dev' + - rich-click ; extra == 'dev' + - doit>=0.36.0 ; extra == 'dev' + - pydevtool ; extra == 'dev' + requires_python: '>=3.10' +- kind: conda + name: setuptools + version: 75.1.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/setuptools-75.1.0-pyhd8ed1ab_0.conda + sha256: 6725235722095c547edd24275053c615158d6163f396550840aebd6e209e4738 + md5: d5cd48392c67fb6849ba459c2c2b671f + depends: + - python >=3.8 + license: MIT + license_family: MIT + purls: + - pkg:pypi/setuptools?source=hash-mapping + size: 777462 + timestamp: 1727249510532 +- kind: pypi + name: six + version: 1.16.0 + url: https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl + sha256: 8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 + requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*' +- kind: pypi + name: stack-data + version: 0.6.3 + url: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl + sha256: d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695 + requires_dist: + - executing>=1.2.0 + - asttokens>=2.1.0 + - pure-eval + - pytest ; extra == 'tests' + - typeguard ; extra == 'tests' + - pygments ; extra == 'tests' + - littleutils ; extra == 'tests' + - cython ; extra == 'tests' +- kind: conda + name: tk + version: 8.6.13 + build: h194ca79_0 + subdir: linux-aarch64 + url: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-h194ca79_0.conda + sha256: 7fa27cc512d3a783f38bd16bbbffc008807372499d5b65d089a8e43bde9db267 + md5: f75105e0585851f818e0009dd1dde4dc + depends: + - libgcc-ng >=12 + - libzlib >=1.2.13,<2.0.0a0 + license: TCL + license_family: BSD + purls: [] + size: 3351802 + timestamp: 1695506242997 +- kind: conda + name: tk + version: 8.6.13 + build: h5083fa2_1 + build_number: 1 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h5083fa2_1.conda + sha256: 72457ad031b4c048e5891f3f6cb27a53cb479db68a52d965f796910e71a403a8 + md5: b50a57ba89c32b62428b71a875291c9b + depends: + - libzlib >=1.2.13,<2.0.0a0 + license: TCL + license_family: BSD + purls: [] + size: 3145523 + timestamp: 1699202432999 +- kind: conda + name: tk + version: 8.6.13 + build: noxft_h4845f30_101 + build_number: 101 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h4845f30_101.conda + sha256: e0569c9caa68bf476bead1bed3d79650bb080b532c64a4af7d8ca286c08dea4e + md5: d453b98d9c83e71da0741bb0ff4d76bc + depends: + - libgcc-ng >=12 + - libzlib >=1.2.13,<2.0.0a0 + license: TCL + license_family: BSD + purls: [] + size: 3318875 + timestamp: 1699202167581 +- kind: pypi + name: tomli + version: 2.0.1 + url: https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl + sha256: 939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc + requires_python: '>=3.7' +- kind: pypi + name: tornado + version: 6.4.1 + url: https://files.pythonhosted.org/packages/00/d9/c33be3c1a7564f7d42d87a8d186371a75fd142097076767a5c27da941fef/tornado-6.4.1-cp38-abi3-macosx_10_9_universal2.whl + sha256: 163b0aafc8e23d8cdc3c9dfb24c5368af84a81e3364745ccb4427669bf84aec8 + requires_python: '>=3.8' +- kind: pypi + name: tornado + version: 6.4.1 + url: https://files.pythonhosted.org/packages/13/cf/786b8f1e6fe1c7c675e79657448178ad65e41c1c9765ef82e7f6f765c4c5/tornado-6.4.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + sha256: e2e20b9113cd7293f164dc46fffb13535266e713cdb87bd2d15ddb336e96cfc4 + requires_python: '>=3.8' +- kind: pypi + name: tornado + version: 6.4.1 + url: https://files.pythonhosted.org/packages/22/d4/54f9d12668b58336bd30defe0307e6c61589a3e687b05c366f804b7faaf0/tornado-6.4.1-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl + sha256: 613bf4ddf5c7a95509218b149b555621497a6cc0d46ac341b30bd9ec19eac7f3 + requires_python: '>=3.8' +- kind: pypi + name: tqdm + version: 4.66.5 + url: https://files.pythonhosted.org/packages/48/5d/acf5905c36149bbaec41ccf7f2b68814647347b72075ac0b1fe3022fdc73/tqdm-4.66.5-py3-none-any.whl + sha256: 90279a3770753eafc9194a0364852159802111925aa30eb3f9d85b0e805ac7cd + requires_dist: + - colorama ; platform_system == 'Windows' + - pytest>=6 ; extra == 'dev' + - pytest-cov ; extra == 'dev' + - pytest-timeout ; extra == 'dev' + - pytest-xdist ; extra == 'dev' + - ipywidgets>=6 ; extra == 'notebook' + - slack-sdk ; extra == 'slack' + - requests ; extra == 'telegram' + requires_python: '>=3.7' +- kind: pypi + name: traitlets + version: 5.14.3 + url: https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl + sha256: b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f + requires_dist: + - myst-parser ; extra == 'docs' + - pydata-sphinx-theme ; extra == 'docs' + - sphinx ; extra == 'docs' + - argcomplete>=3.0.3 ; extra == 'test' + - mypy>=1.7.0 ; extra == 'test' + - pre-commit ; extra == 'test' + - pytest-mock ; extra == 'test' + - pytest-mypy-testing ; extra == 'test' + - pytest<8.2,>=7.0 ; extra == 'test' + requires_python: '>=3.8' +- kind: pypi + name: trove-classifiers + version: 2024.9.12 + url: https://files.pythonhosted.org/packages/b6/7a/e0edec9c8905e851d52076bbc41890603e2ba97cf64966bc1498f2244fd2/trove_classifiers-2024.9.12-py3-none-any.whl + sha256: f88a27a892891c87c5f8bbdf110710ae9e0a4725ea8e0fb45f1bcadf088a491f +- kind: pypi + name: types-pytz + version: 2024.2.0.20240913 + url: https://files.pythonhosted.org/packages/b6/a6/8846372f55c6bb470ff7207e4dc601017e264e5fe7d79a441ece3545b36c/types_pytz-2024.2.0.20240913-py3-none-any.whl + sha256: a1eebf57ebc6e127a99d2fa2ba0a88d2b173784ef9b3defcc2004ab6855a44df + requires_python: '>=3.8' +- kind: pypi + name: typing-extensions + version: 4.12.2 + url: https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl + sha256: 04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d + requires_python: '>=3.8' +- kind: pypi + name: tzdata + version: '2024.1' + url: https://files.pythonhosted.org/packages/65/58/f9c9e6be752e9fcb8b6a0ee9fb87e6e7a1f6bcab2cdc73f02bb7ba91ada0/tzdata-2024.1-py2.py3-none-any.whl + sha256: 9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252 + requires_python: '>=2' +- kind: conda + name: tzdata + version: 2024a + build: h8827d51_1 + build_number: 1 + subdir: noarch + noarch: generic + url: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h8827d51_1.conda + sha256: 7d21c95f61319dba9209ca17d1935e6128af4235a67ee4e57a00908a1450081e + md5: 8bfdead4e0fff0383ae4c9c50d0531bd + license: LicenseRef-Public-Domain + purls: [] + size: 124164 + timestamp: 1724736371498 +- kind: pypi + name: urllib3 + version: 2.2.3 + url: https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl + sha256: ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac + requires_dist: + - brotli>=1.0.9 ; platform_python_implementation == 'CPython' and extra == 'brotli' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'brotli' + - h2<5,>=4 ; extra == 'h2' + - pysocks!=1.5.7,<2.0,>=1.5.6 ; extra == 'socks' + - zstandard>=0.18.0 ; extra == 'zstd' + requires_python: '>=3.8' +- kind: pypi + name: wcwidth + version: 0.2.13 + url: https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl + sha256: 3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859 + requires_dist: + - backports-functools-lru-cache>=1.2.1 ; python_full_version < '3.2' +- kind: conda + name: wheel + version: 0.44.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/wheel-0.44.0-pyhd8ed1ab_0.conda + sha256: d828764736babb4322b8102094de38074dedfc71f5ff405c9dfee89191c14ebc + md5: d44e3b085abcaef02983c6305b84b584 + depends: + - python >=3.8 + license: MIT + license_family: MIT + purls: + - pkg:pypi/wheel?source=hash-mapping + size: 58585 + timestamp: 1722797131787 +- kind: conda + name: xz + version: 5.2.6 + build: h166bdaf_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/xz-5.2.6-h166bdaf_0.tar.bz2 + sha256: 03a6d28ded42af8a347345f82f3eebdd6807a08526d47899a42d62d319609162 + md5: 2161070d867d1b1204ea749c8eec4ef0 + depends: + - libgcc-ng >=12 + license: LGPL-2.1 and GPL-2.0 + purls: [] + size: 418368 + timestamp: 1660346797927 +- kind: conda + name: xz + version: 5.2.6 + build: h57fd34a_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/xz-5.2.6-h57fd34a_0.tar.bz2 + sha256: 59d78af0c3e071021cfe82dc40134c19dab8cdf804324b62940f5c8cd71803ec + md5: 39c6b54e94014701dd157f4f576ed211 + license: LGPL-2.1 and GPL-2.0 + purls: [] + size: 235693 + timestamp: 1660346961024 +- kind: conda + name: xz + version: 5.2.6 + build: h9cdd2b7_0 + subdir: linux-aarch64 + url: https://conda.anaconda.org/conda-forge/linux-aarch64/xz-5.2.6-h9cdd2b7_0.tar.bz2 + sha256: 93f58a7b393adf41fa007ac8c55978765e957e90cd31877ece1e5a343cb98220 + md5: 83baad393a31d59c20b63ba4da6592df + depends: + - libgcc-ng >=12 + license: LGPL-2.1 and GPL-2.0 + purls: [] + size: 440555 + timestamp: 1660348056328 diff --git a/pyproject.toml b/pyproject.toml index aba0058..9cf7039 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,26 +1,63 @@ -[tool.poetry] +[project] name = "openprotein_python" -packages = [{include = "openprotein"}] -version = "0.4.1" +packages = [{ include = "openprotein" }] +version = "0.5.0" description = "OpenProtein Python interface." license = "MIT" readme = "README.md" homepage = "https://docs.openprotein.ai/" -authors = ["OpenProtein "] +authors = [{ name = "Mark Gee", email = "markgee@ne47.bio" }] classifiers = [ - "Development Status :: 4 - Beta", - "Programming Language :: Python :: 3", + "Development Status :: 4 - Beta", + "Programming Language :: Python :: 3", ] +dependencies = [ + "requests>=2.32.3,<3", + "pydantic>=2.5,<3", + "tqdm>=4.66.5,<5", + "pandas>=2.1.4,<3", + "numpy>=2.1.1,<3", +] +requires-python = ">=3.10,<3.11" + +[project.optional-dependencies] +dev = [ + "ipykernel>=6.29.5,<7", + "pytest>=8.3.3,<9", + "pandas-stubs>=2.1.4.240909,<3", + "hatchling>=1.25.0,<2", + "matplotlib>=3.9.2,<4", + "scipy>=1.14.1,<2", +] + +[tool.pixi.project] +channels = ["conda-forge"] +platforms = ["linux-64", "linux-aarch64", "osx-arm64"] + +[tool.pixi.dependencies] +python = ">=3.10,<3.11" -[tool.poetry.dependencies] -python = "^3.8" -requests = ">=2" -pydantic = ">=1" -tqdm = ">=4" -pandas = ">=1" +[tool.pixi.environments] +default = { solve-group = "default" } +dev = { features = ["dev"], solve-group = "default" } -[tool.poetry.dev-dependencies] +[tool.pixi.tasks] +postinstall = "pip install --no-build-isolation --no-deps --disable-pip-version-check -e ." + +[tool.pixi.feature.dev.dependencies] +pip = ">=24.2,<25" +editables = ">=0.5,<0.6" [build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" +build-backend = "hatchling.build" +requires = ["hatchling"] + +[tool.hatch.build.targets.sdist] +packages = ["openprotein"] + +[tool.hatch.build.targets.wheel] +packages = ["openprotein"] + +[tool.pyright] +# typeCheckingMode = "off" # this shit too hard LOL +typeCheckingMode = "basic" # LETS DO THIS SHIT