From adbc0c1a90712f4c7f8dc73de6b283d0546eb638 Mon Sep 17 00:00:00 2001 From: Alexander Berger Date: Mon, 14 Aug 2023 09:48:42 -0400 Subject: [PATCH] Adding in progress src and tests code --- src/geneweaver/api/__init__.py | 0 src/geneweaver/api/controller/__init__.py | 0 src/geneweaver/api/controller/api.py | 32 ++ src/geneweaver/api/controller/batch.py | 32 ++ src/geneweaver/api/controller/genesets.py | 55 +++ src/geneweaver/api/controller/search.py | 1 + src/geneweaver/api/core/__init__.py | 0 src/geneweaver/api/core/config.py | 8 + src/geneweaver/api/core/config_class.py | 45 ++ src/geneweaver/api/core/db.py | 0 src/geneweaver/api/core/deps.py | 12 + src/geneweaver/api/core/exceptions.py | 36 ++ src/geneweaver/api/core/security.py | 251 +++++++++++ src/geneweaver/api/dependencies.py | 36 ++ src/geneweaver/api/main.py | 1 + src/geneweaver/api/schemas/__init__.py | 0 src/geneweaver/api/schemas/auth.py | 27 ++ src/geneweaver/api/schemas/batch.py | 65 +++ src/geneweaver/api/schemas/messages.py | 40 ++ src/geneweaver/api/schemas/score.py | 20 + src/geneweaver/api/services/__init__.py | 0 src/geneweaver/api/services/batch.py | 58 +++ src/geneweaver/api/services/geneset.py | 0 src/geneweaver/api/services/io.py | 19 + src/geneweaver/api/services/parse/__init__.py | 0 src/geneweaver/api/services/parse/batch.py | 47 ++ src/geneweaver/api/services/pubmeds.py | 0 tests/__init__.py | 0 tests/api/__init__.py | 0 tests/api/unit/__init__.py | 0 tests/api/unit/services/__init__.py | 0 tests/api/unit/services/io/__init__.py | 0 .../services/io/test_read_file_contents.py | 86 ++++ tests/api/unit/services/parse/__init__.py | 0 .../api/unit/services/parse/batch/__init__.py | 0 .../api/unit/services/parse/batch/conftest.py | 26 ++ tests/api/unit/services/parse/batch/const.py | 401 ++++++++++++++++++ .../unit/services/parse/batch/test_parse.py | 0 tests/db/__init__.py | 0 39 files changed, 1298 insertions(+) create mode 100644 src/geneweaver/api/__init__.py create mode 100644 src/geneweaver/api/controller/__init__.py create mode 100644 src/geneweaver/api/controller/api.py create mode 100644 src/geneweaver/api/controller/batch.py create mode 100644 src/geneweaver/api/controller/genesets.py create mode 100644 src/geneweaver/api/controller/search.py create mode 100644 src/geneweaver/api/core/__init__.py create mode 100644 src/geneweaver/api/core/config.py create mode 100644 src/geneweaver/api/core/config_class.py create mode 100644 src/geneweaver/api/core/db.py create mode 100644 src/geneweaver/api/core/deps.py create mode 100644 src/geneweaver/api/core/exceptions.py create mode 100644 src/geneweaver/api/core/security.py create mode 100644 src/geneweaver/api/dependencies.py create mode 100644 src/geneweaver/api/main.py create mode 100644 src/geneweaver/api/schemas/__init__.py create mode 100644 src/geneweaver/api/schemas/auth.py create mode 100644 src/geneweaver/api/schemas/batch.py create mode 100644 src/geneweaver/api/schemas/messages.py create mode 100644 src/geneweaver/api/schemas/score.py create mode 100644 src/geneweaver/api/services/__init__.py create mode 100644 src/geneweaver/api/services/batch.py create mode 100644 src/geneweaver/api/services/geneset.py create mode 100644 src/geneweaver/api/services/io.py create mode 100644 src/geneweaver/api/services/parse/__init__.py create mode 100644 src/geneweaver/api/services/parse/batch.py create mode 100644 src/geneweaver/api/services/pubmeds.py create mode 100644 tests/__init__.py create mode 100644 tests/api/__init__.py create mode 100644 tests/api/unit/__init__.py create mode 100644 tests/api/unit/services/__init__.py create mode 100644 tests/api/unit/services/io/__init__.py create mode 100644 tests/api/unit/services/io/test_read_file_contents.py create mode 100644 tests/api/unit/services/parse/__init__.py create mode 100644 tests/api/unit/services/parse/batch/__init__.py create mode 100644 tests/api/unit/services/parse/batch/conftest.py create mode 100644 tests/api/unit/services/parse/batch/const.py create mode 100644 tests/api/unit/services/parse/batch/test_parse.py create mode 100644 tests/db/__init__.py diff --git a/src/geneweaver/api/__init__.py b/src/geneweaver/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/geneweaver/api/controller/__init__.py b/src/geneweaver/api/controller/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/geneweaver/api/controller/api.py b/src/geneweaver/api/controller/api.py new file mode 100644 index 0000000..4530ec8 --- /dev/null +++ b/src/geneweaver/api/controller/api.py @@ -0,0 +1,32 @@ +"""The root API definition for the GeneWeaver API. + +This file defines the root API for the GeneWeaver API. It is responsible for +defining the FastAPI application and including all other API routers. +""" +from fastapi import FastAPI, APIRouter, Security + +from geneweaver.api.core import deps +from geneweaver.api.core.config import settings + +from geneweaver.api.controller import batch +from geneweaver.api.controller import genesets + +app = FastAPI( + title="GeneWeaver API", + docs_url=f"{settings.API_PREFIX}/docs", + redoc_url=f"{settings.API_PREFIX}/redoc", + openapi_url=f"{settings.API_PREFIX}/openapi.json", + swagger_ui_oauth2_redirect_url=f"{settings.API_PREFIX}/docs/oauth2-redirect", + swagger_ui_init_oauth={"clientId": settings.AUTH_CLIENT_ID}, +) + +api_router = APIRouter( + tags=["api"], + dependencies=[ + Security(deps.auth.implicit_scheme), + ], +) +api_router.include_router(batch.router) +api_router.include_router(genesets.router) + +app.include_router(api_router, prefix=settings.API_PREFIX) diff --git a/src/geneweaver/api/controller/batch.py b/src/geneweaver/api/controller/batch.py new file mode 100644 index 0000000..07a003f --- /dev/null +++ b/src/geneweaver/api/controller/batch.py @@ -0,0 +1,32 @@ +"""API Controller definition for batch processing.""" +from typing import Optional + +from fastapi import APIRouter, UploadFile, Security + +from geneweaver.api.core import deps +from geneweaver.api.schemas.auth import UserInternal +from geneweaver.api.schemas.batch import BatchResponse +from geneweaver.api.services.parse.batch import process_batch_file + +router = APIRouter(prefix="/batch") + + +@router.post(path="") +async def batch( + batch_file: UploadFile, + curation_group_id: Optional[int] = None, + user: UserInternal = Security(deps.auth.get_user_strict), +) -> BatchResponse: + """Submit a batch file for processing.""" + user_id = 1 # TODO: Get user ID from session + genesets, user_messages, system_messages = await process_batch_file( + batch_file, user_id + ) + + return { + "genesets": genesets, + "messages": { + "user_messages": user_messages, + "system_messages": system_messages, + }, + } diff --git a/src/geneweaver/api/controller/genesets.py b/src/geneweaver/api/controller/genesets.py new file mode 100644 index 0000000..2973b3b --- /dev/null +++ b/src/geneweaver/api/controller/genesets.py @@ -0,0 +1,55 @@ +"""Endpoints related to genesets.""" +from typing import Optional + +from fastapi import APIRouter, Security, Depends, HTTPException + +from geneweaver.api import dependencies as deps +from geneweaver.api.schemas.auth import UserInternal +from geneweaver.db import geneset_value as db_geneset_value +from geneweaver.db import geneset as db_geneset +from geneweaver.db import gene as db_gene +from geneweaver.db.geneset import by_id, by_user_id, is_readable +from geneweaver.db.geneset_value import by_geneset_id +from geneweaver.db.user import by_sso_id +from geneweaver.core.schema.geneset import GenesetUpload + + +router = APIRouter(prefix="/genesets") + + +@router.get("") +def get_visible_genesets( + user: UserInternal = Security(deps.full_user), + cursor: Optional[deps.Cursor] = Depends(deps.cursor), +): + """Get all visible genesets.""" + user_genesets = db_geneset.by_user_id(cursor, user.id) + return {"genesets": user_genesets} + + +@router.get("/{geneset_id}") +def get_geneset( + geneset_id: int, + user: UserInternal = Security(deps.full_user), + cursor: Optional[deps.Cursor] = Depends(deps.cursor), +): + """Get a geneset by ID.""" + if not is_readable(cursor, user.id, geneset_id): + raise HTTPException(status_code=403, detail="Forbidden") + + geneset = db_geneset.by_id(cursor, geneset_id) + geneset_values = db_geneset_value.by_geneset_id(cursor, geneset_id) + return {"geneset": geneset, "geneset_values": geneset_values} + + +@router.post("") +def upload_geneset( + geneset: GenesetUpload, + user: UserInternal = Security(deps.full_user), + cursor: Optional[deps.Cursor] = Depends(deps.cursor), +): + """Upload a geneset.""" + formatted_geneset_values = db_geneset_value.format_geneset_values_for_file_insert( + geneset.gene_list + ) + return {"geneset_id": 0} \ No newline at end of file diff --git a/src/geneweaver/api/controller/search.py b/src/geneweaver/api/controller/search.py new file mode 100644 index 0000000..5bd408f --- /dev/null +++ b/src/geneweaver/api/controller/search.py @@ -0,0 +1 @@ +"""Endpoints related to searching.""" diff --git a/src/geneweaver/api/core/__init__.py b/src/geneweaver/api/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/geneweaver/api/core/config.py b/src/geneweaver/api/core/config.py new file mode 100644 index 0000000..b4e7e17 --- /dev/null +++ b/src/geneweaver/api/core/config.py @@ -0,0 +1,8 @@ +"""A namespace for the initialized Geneweaver API configuration.""" +from geneweaver.db.core.settings_class import Settings as DBSettings + +from geneweaver.api.core.config_class import GeneweaverAPIConfig + +settings = GeneweaverAPIConfig() + +db_settings = DBSettings() diff --git a/src/geneweaver/api/core/config_class.py b/src/geneweaver/api/core/config_class.py new file mode 100644 index 0000000..a0193c9 --- /dev/null +++ b/src/geneweaver/api/core/config_class.py @@ -0,0 +1,45 @@ +"""Namespace for the config class for the Geneweaver API.""" +from typing import Any, Dict, Optional, List + +from pydantic import BaseSettings, PostgresDsn, validator +from geneweaver.db.core.settings_class import Settings + + +class GeneweaverAPIConfig(BaseSettings): + """Config class for the Geneweaver API.""" + + API_PREFIX: str = "" + + POSTGRES_SERVER: str + POSTGRES_USER: str + POSTGRES_PASSWORD: str + POSTGRES_DB: str + SQLALCHEMY_DATABASE_URI: Optional[PostgresDsn] = None + + @validator("SQLALCHEMY_DATABASE_URI", pre=True) + def assemble_db_connection(cls, v: Optional[str], values: Dict[str, Any]) -> Any: + if isinstance(v, str): + return v + return PostgresDsn.build( + scheme="postgresql", + user=values.get("POSTGRES_USER"), + password=values.get("POSTGRES_PASSWORD"), + host=values.get("POSTGRES_SERVER"), + path=f"/{values.get('POSTGRES_DB') or ''}", + ) + + AUTH_DOMAIN: str = "geneweaver.auth0.com" + AUTH_AUDIENCE: str = "https://api.geneweaver.org" + AUTH_ALGORITHMS: List[str] = ["RS256"] + AUTH_EMAIL_NAMESPACE: str = AUTH_AUDIENCE + AUTH_SCOPES = { + "openid profile email": "read", + } + JWT_PERMISSION_PREFIX: str = "approle" + AUTH_CLIENT_ID: str = "oVm9omUtLBpVyL7YfJA8gp3hHaHwyVt8" + + class Config: + """Configuration for the BaseSettings class.""" + + env_file = ".env" + case_sensitive = True diff --git a/src/geneweaver/api/core/db.py b/src/geneweaver/api/core/db.py new file mode 100644 index 0000000..e69de29 diff --git a/src/geneweaver/api/core/deps.py b/src/geneweaver/api/core/deps.py new file mode 100644 index 0000000..76d85a2 --- /dev/null +++ b/src/geneweaver/api/core/deps.py @@ -0,0 +1,12 @@ +"""A module to keep track of injectable dependencies for FastAPI endpoints. +- https://fastapi.tiangolo.com/tutorial/dependencies/ +""" +from geneweaver.api.core.config import settings +from geneweaver.api.core.security import Auth0 + +auth = Auth0( + domain=settings.AUTH_DOMAIN, + api_audience=settings.AUTH_AUDIENCE, + scopes=settings.AUTH_SCOPES, + auto_error=False, +) diff --git a/src/geneweaver/api/core/exceptions.py b/src/geneweaver/api/core/exceptions.py new file mode 100644 index 0000000..4ea267a --- /dev/null +++ b/src/geneweaver/api/core/exceptions.py @@ -0,0 +1,36 @@ +"""""" +from fastapi import HTTPException + + +class Auth0UnauthenticatedException(HTTPException): + def __init__(self, **kwargs): + super().__init__(401, **kwargs) + + +class Auth0UnauthorizedException(HTTPException): + def __init__(self, **kwargs): + super().__init__(403, **kwargs) + + +class NotAHeaderRowError(Exception): + pass + + +class InvalidBatchValueLine(Exception): + pass + + +class MultiLineStringError(Exception): + pass + + +class IgnoreLineError(Exception): + pass + + +class MissingRequiredHeaderError(Exception): + pass + + +class InvalidScoreThresholdException(Exception): + pass diff --git a/src/geneweaver/api/core/security.py b/src/geneweaver/api/core/security.py new file mode 100644 index 0000000..98a01a0 --- /dev/null +++ b/src/geneweaver/api/core/security.py @@ -0,0 +1,251 @@ +"""This file contains code to authenticate a user to the API.""" +import requests +import urllib.parse + +from typing import Optional, Dict, Type + +from fastapi import HTTPException, Depends, Request +from fastapi.logger import logger +from fastapi.security import SecurityScopes, HTTPBearer, HTTPAuthorizationCredentials +from fastapi.security import OAuth2 +from fastapi.openapi.models import OAuthFlows +from pydantic import ValidationError +from jose import jwt # type: ignore + +from geneweaver.api.schemas.auth import UserInternal + +from geneweaver.api.core.exceptions import ( + Auth0UnauthenticatedException, + Auth0UnauthorizedException, +) + + +class Auth0HTTPBearer(HTTPBearer): + async def __call__(self, request: Request): + # logger.debug('Called Auth0HTTPBearer') + return await super().__call__(request) + + +class OAuth2ImplicitBearer(OAuth2): + def __init__( + self, + authorizationUrl: str, + scopes: Dict[str, str] = {}, + scheme_name: Optional[str] = None, + auto_error: bool = True, + ): + flows = OAuthFlows( + implicit={"authorizationUrl": authorizationUrl, "scopes": scopes} + ) + super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error) + + async def __call__(self, request: Request) -> Optional[str]: + # Overwrite parent call to prevent useless overhead, the actual auth is done in Auth0.get_user + # This scheme is just for Swagger UI + return None + + +class Auth0: + def __init__( + self, + domain: str, + api_audience: str, + scopes: Dict[str, str] = {}, + auto_error: bool = True, + scope_auto_error: bool = True, + email_auto_error: bool = False, + auth0user_model: Type[UserInternal] = UserInternal, + ): + self.domain = domain + self.audience = api_audience + + self.auto_error = auto_error + self.scope_auto_error = scope_auto_error + self.email_auto_error = email_auto_error + + self.auth0_user_model = auth0user_model + + self.algorithms = ["RS256"] + self.jwks: Dict = requests.get(f"https://{domain}/.well-known/jwks.json").json() + + authorization_url_qs = urllib.parse.urlencode({"audience": api_audience}) + authorization_url = f"https://{domain}/authorize?{authorization_url_qs}" + self.implicit_scheme = OAuth2ImplicitBearer( + authorizationUrl=authorization_url, + scopes=scopes, + scheme_name="Auth0ImplicitBearer", + ) + + async def public( + self, + security_scopes: SecurityScopes, + creds: Optional[HTTPAuthorizationCredentials] = Depends( + Auth0HTTPBearer(auto_error=False) + ), + ) -> bool: + return not bool(await self.get_user(security_scopes, creds)) + + async def authenticated( + self, + security_scopes: SecurityScopes, + creds: Optional[HTTPAuthorizationCredentials] = Depends( + Auth0HTTPBearer(auto_error=False) + ), + ) -> bool: + try: + authenticated = bool(await self.get_user(security_scopes, creds)) + except (Auth0UnauthorizedException, HTTPException): + authenticated = False + return authenticated + + async def get_auth_header( + self, + security_scopes: SecurityScopes, + creds: Optional[HTTPAuthorizationCredentials] = Depends( + Auth0HTTPBearer(auto_error=False) + ), + auto_error_auth: Optional[bool] = False, + ) -> Optional[Dict[str, str]]: + user = await self.get_user(security_scopes, creds, auto_error_auth) + return user.auth_header if user else None + + async def get_user_strict( + self, + security_scopes: SecurityScopes, + creds: Optional[HTTPAuthorizationCredentials] = Depends( + Auth0HTTPBearer(auto_error=False) + ), + ): + return await self.get_user(security_scopes, creds, True) + + async def get_user( + self, + security_scopes: SecurityScopes, + creds: Optional[HTTPAuthorizationCredentials] = Depends( + Auth0HTTPBearer(auto_error=False) + ), + auto_error_auth: Optional[bool] = True, + disallow_public: Optional[bool] = True, + ) -> Optional[UserInternal]: + auto_error_auth = ( + self.auto_error if auto_error_auth is None else auto_error_auth + ) + logger.debug(f"`auto_error` is {'ON' if auto_error_auth else 'OFF'}") + logger.debug(f"`disallow_public` is {'ON' if disallow_public else 'OFF'}") + if creds is None: + if disallow_public: + logger.debug(f"No credentials found, raising HTTP 403 exception") + # See HTTPBearer from FastAPI: + # latest - https://github.com/tiangolo/fastapi/blob/master/fastapi/security/http.py + # 0.65.1 - https://github.com/tiangolo/fastapi/blob/aece74982d7c9c1acac98e2c872c4cb885677fc7/fastapi/security/http.py + # must be 403 until solving https://github.com/tiangolo/fastapi/pull/2120 + raise HTTPException(403, detail="Missing bearer token") + else: + logger.debug(f"No credentials found, returning None") + return None + + token = creds.credentials + payload: Dict = {} + try: + unverified_header = jwt.get_unverified_header(token) + rsa_key = {} + for key in self.jwks["keys"]: + if key["kid"] == unverified_header["kid"]: + rsa_key = { + "kty": key["kty"], + "kid": key["kid"], + "use": key["use"], + "n": key["n"], + "e": key["e"], + } + # break # TODO: do we still need to iterate all keys after we found a match? + if rsa_key: + payload = jwt.decode( + token, + rsa_key, + algorithms=self.algorithms, + audience=self.audience, + issuer=f"https://{self.domain}/", + ) + logger.debug(f"Decoded header token: {payload}") + else: + if auto_error_auth: + raise jwt.JWTError + + except jwt.ExpiredSignatureError: + if auto_error_auth: + raise Auth0UnauthenticatedException(detail="Expired token") + else: + return None + + except jwt.JWTClaimsError: + if auto_error_auth: + raise Auth0UnauthenticatedException( + detail="Invalid token claims (please check issuer and audience)" + ) + else: + return None + + except jwt.JWTError: + if auto_error_auth: + raise Auth0UnauthenticatedException(detail="Malformed token") + else: + return None + + except Exception as e: + logger.error(f'Handled exception decoding token: "{e}"') + if auto_error_auth: + raise Auth0UnauthenticatedException(detail="Error decoding token") + else: + return None + + if self.scope_auto_error: + token_scope_str: str = payload.get("scope", "") + + if isinstance(token_scope_str, str): + token_scopes = token_scope_str.split() + + for scope in security_scopes.scopes: + if scope not in token_scopes: + raise Auth0UnauthorizedException( + detail=f'Missing "{scope}" scope', + headers={ + "WWW-Authenticate": f'Bearer scope="{security_scopes.scope_str}"' + }, + ) + else: + # This is an unlikely case but handle it just to be safe (perhaps auth0 will change the scope format) + raise Auth0UnauthorizedException( + detail='Token "scope" field must be a string' + ) + + try: + self._add_auth_info(token, payload) + self._process_payload(payload) + user = self.auth0_user_model(**payload) + if self.email_auto_error and not user.email: + raise Auth0UnauthorizedException( + detail=f'Missing email claim (check auth0 rule "Add email to access token")' + ) + + logger.info(f"Successfully found user in header token: {user}") + return user + + except ValidationError as e: + logger.error(f'Handled exception parsing Auth0User: "{e}"') + if auto_error_auth: + raise Auth0UnauthorizedException(detail="Error parsing Auth0User") + else: + return None + + return None + + def _process_payload(self, payload: dict): + self._process_email(payload) + + def _add_auth_info(self, token: str, payload: dict): + payload["token"] = token + payload["auth_header"] = {"Authorization": f"Bearer {token}"} + + def _process_email(self, payload: dict): + payload["email"] = payload.pop(f"{self.audience}/claims/email") diff --git a/src/geneweaver/api/dependencies.py b/src/geneweaver/api/dependencies.py new file mode 100644 index 0000000..6051a1d --- /dev/null +++ b/src/geneweaver/api/dependencies.py @@ -0,0 +1,36 @@ +"""Dependency injection capabilities for the GeneWeaver API.""" +from typing import Generator +from fastapi import Depends +import psycopg +from psycopg.rows import dict_row +# from psycopg_pool import ConnectionPool +# pool = ConnectionPool(conninfo, **kwargs) + +from geneweaver.api.core.config import settings, db_settings +from geneweaver.db.user import user_id_from_sso_id +from geneweaver.api.core.security import Auth0, UserInternal +from geneweaver.db.user import by_sso_id + +auth = Auth0( + domain=settings.AUTH_DOMAIN, + api_audience=settings.AUTH_AUDIENCE, + scopes=settings.AUTH_SCOPES, + auto_error=False, +) + +Cursor = psycopg.Cursor + + +def cursor() -> Generator: + """Get a cursor from the connection pool.""" + with psycopg.connect(db_settings.URI, row_factory=dict_row) as conn: + with conn.cursor() as cur: + yield cur + + +def full_user( + cursor: Cursor = Depends(cursor), + user: UserInternal = Depends(auth.get_user_strict), +) -> UserInternal: + user.id = by_sso_id(cursor, user.sso_id)[0]['usr_id'] + yield user diff --git a/src/geneweaver/api/main.py b/src/geneweaver/api/main.py new file mode 100644 index 0000000..ab746ab --- /dev/null +++ b/src/geneweaver/api/main.py @@ -0,0 +1 @@ +from geneweaver.api.controller.api import app diff --git a/src/geneweaver/api/schemas/__init__.py b/src/geneweaver/api/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/geneweaver/api/schemas/auth.py b/src/geneweaver/api/schemas/auth.py new file mode 100644 index 0000000..57278f6 --- /dev/null +++ b/src/geneweaver/api/schemas/auth.py @@ -0,0 +1,27 @@ +from enum import Enum +from typing import Optional, List + +from pydantic import BaseModel, Field + + +class AppRoles(str, Enum): + user = "user" + curator = "curator" + admin = "admin" + + +class User(BaseModel): + email: Optional[str] + name: Optional[str] + sso_id: str = Field(None, alias="sub") + id: int = Field(None, alias="gw_id") + role: Optional[AppRoles] = AppRoles.user + + +class UserInternal(User): + auth_header: dict = {} + token: str + permissions: Optional[List[str]] + + class Config: + allow_population_by_field_name = True diff --git a/src/geneweaver/api/schemas/batch.py b/src/geneweaver/api/schemas/batch.py new file mode 100644 index 0000000..7a354a9 --- /dev/null +++ b/src/geneweaver/api/schemas/batch.py @@ -0,0 +1,65 @@ +"""Module for defining schemas for batch endpoints.""" +from typing import List, Optional + +from pydantic import BaseModel, validator +from geneweaver.core.parse.score import parse_score + +from geneweaver.api.schemas.messages import MessageResponse +from geneweaver.api.schemas.score import GenesetScoreType + + +class BatchResponse(BaseModel): + """Class for defining a response containing batch results.""" + + genesets: List[int] + messages: MessageResponse + + +class Publication(BaseModel): + authors: str + title: str + abstract: str + journal: str + volume: str + pages: str + month: str + year: str + pubmed: str + + +class GenesetValueInput(BaseModel): + symbol: str + value: float + + +class GenesetValue(BaseModel): + ode_gene_id: str + value: float + ode_ref_id: str + threshold: bool + + +class BatchUploadGeneset(BaseModel): + score: GenesetScoreType + # TODO: Use enum from core + species: str + gene_id_type: str + pubmed_id: str + private: bool = True + curation_id: Optional[int] = None + abbreviation: str + name: str + description: str + values: List[GenesetValueInput] + + @validator("score", pre=True) + def initialize_score(cls, v): + return parse_score(v) + + @validator("private", pre=True) + def private_to_bool(cls, v): + return v.lower() != "public" + + @validator("curation_id", pre=True) + def curation_id_to_int(cls, v, values): + return 5 if values["private"] else 4 diff --git a/src/geneweaver/api/schemas/messages.py b/src/geneweaver/api/schemas/messages.py new file mode 100644 index 0000000..f751106 --- /dev/null +++ b/src/geneweaver/api/schemas/messages.py @@ -0,0 +1,40 @@ +"""Namespace for defining schemas related to messaging.""" +import enum +from typing import List, Optional + +from pydantic import BaseModel + + +class MessageType(enum.Enum): + """Enum for defining the type of message.""" + + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +class Message(BaseModel): + """Base class for defining a message.""" + + message: str + message_type: MessageType + detail: Optional[str] = None + + +class UserMessage(Message): + """Class for defining a message for a user.""" + + ... + + +class SystemMessage(Message): + """Class for defining a message for the system.""" + + ... + + +class MessageResponse(BaseModel): + """Class for defining a response containing messages.""" + + user_messages: Optional[List[UserMessage]] = None + system_messages: Optional[List[SystemMessage]] = None diff --git a/src/geneweaver/api/schemas/score.py b/src/geneweaver/api/schemas/score.py new file mode 100644 index 0000000..ffb339a --- /dev/null +++ b/src/geneweaver/api/schemas/score.py @@ -0,0 +1,20 @@ +from enum import Enum +from typing import Optional + +from pydantic import BaseModel + + +class ScoreType(Enum): + """Enum for defining score types.""" + + P_VALUE = 1 + Q_VALUE = 2 + BINARY = 3 + CORRELATION = 4 + EFFECT = 5 + + +class GenesetScoreType(BaseModel): + score_type: ScoreType + threshold_low: Optional[float] = None + threshold: float = 0.05 diff --git a/src/geneweaver/api/services/__init__.py b/src/geneweaver/api/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/geneweaver/api/services/batch.py b/src/geneweaver/api/services/batch.py new file mode 100644 index 0000000..7510803 --- /dev/null +++ b/src/geneweaver/api/services/batch.py @@ -0,0 +1,58 @@ +"""Service functions for dealing with batch files.""" +from enum import Enum +from typing import List, Tuple + +from fastapi import UploadFile + +from geneweaver.core.parse import batch + +from geneweaver.api.schemas.batch import BatchUploadGeneset, GenesetValueInput +from geneweaver.api.schemas.messages import SystemMessage, UserMessage + + +async def process_batch_file( + batch_file: UploadFile, + user_id: int, +) -> Tuple[List[int], List[UserMessage], List[SystemMessage]]: + """Asynchronously processes a batch file for geneset information. + + This function reads the contents of a batch file and processes each line to extract + geneset information. Exceptions encountered during processing are caught and + returned as UserMessage and SystemMessage instances. + + Note: The function is not complete and currently returns placeholder values. + + :param batch_file: An instance of UploadFile representing the file to be processed. + :param user_id: The ID of the user performing the operation. + + :returns: A tuple containing placeholder data: + 0. The first element is a list of integers, + 1. the second is a list of UserMessage instances, + 2. and the third is a list of SystemMessage instances. + """ + contents = await read_file_contents(batch_file) + genesets = batch.process_lines(contents) + + # TODO: Remove this print statement. + for geneset in genesets: + print(geneset, "\n") + + # TODO: Return the correct values. + return [10], [], [] + + +async def read_file_contents(batch_file: UploadFile, encoding: str = "utf-8") -> str: + """Reads the contents of an async file and decodes it using a specified encoding. + + This function uses an asynchronous read operation to get the contents of the + batch_file, and then decodes those contents from bytes to a string using the + provided encoding. The default encoding is UTF-8. + + :param batch_file: An instance of UploadFile representing the file to be read. + :param encoding: The character encoding to use when decoding the file contents. + Default is 'utf-8'. + + :returns: The contents of the file as a string decoded using the specified encoding. + """ + contents = await batch_file.read() + return contents.decode(encoding) diff --git a/src/geneweaver/api/services/geneset.py b/src/geneweaver/api/services/geneset.py new file mode 100644 index 0000000..e69de29 diff --git a/src/geneweaver/api/services/io.py b/src/geneweaver/api/services/io.py new file mode 100644 index 0000000..e3f7c55 --- /dev/null +++ b/src/geneweaver/api/services/io.py @@ -0,0 +1,19 @@ +"""Services for reading and writing files.""" +from fastapi import UploadFile + + +async def read_file_contents(batch_file: UploadFile, encoding: str = "utf-8") -> str: + """Reads the contents of an async file and decodes it using a specified encoding. + + This function uses an asynchronous read operation to get the contents of the + batch_file, and then decodes those contents from bytes to a string using the + provided encoding. The default encoding is UTF-8. + + :param batch_file: An instance of UploadFile representing the file to be read. + :param encoding: The character encoding to use when decoding the file contents. + Default is 'utf-8'. + + :returns: The contents of the file as a string decoded using the specified encoding. + """ + contents = await batch_file.read() + return contents.decode(encoding) diff --git a/src/geneweaver/api/services/parse/__init__.py b/src/geneweaver/api/services/parse/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/geneweaver/api/services/parse/batch.py b/src/geneweaver/api/services/parse/batch.py new file mode 100644 index 0000000..ad38b3d --- /dev/null +++ b/src/geneweaver/api/services/parse/batch.py @@ -0,0 +1,47 @@ +from typing import List, Tuple +from fastapi import UploadFile + +from geneweaver.core.parse import batch +from geneweaver.core.schema.messages import SystemMessage, UserMessage + + +from geneweaver.api.services.io import read_file_contents + + +async def process_batch_file( + # TODO: Add the database session to the function signature. + # db: Session, + batch_file: UploadFile, + user_id: int, +) -> Tuple[List[int], List[UserMessage], List[SystemMessage]]: + """Asynchronously processes a batch file for geneset information. + + This function reads the contents of a batch file and processes each line to extract + geneset information. Exceptions encountered during processing are caught and + returned as UserMessage and SystemMessage instances. + + Note: The function is not complete and currently returns placeholder values. + + :param batch_file: An instance of UploadFile representing the file to be processed. + :param user_id: The ID of the user performing the operation. + + :returns: A tuple containing placeholder data: + 0. The first element is a list of integers, + 1. the second is a list of UserMessage instances, + 2. and the third is a list of SystemMessage instances. + """ + contents = await read_file_contents(batch_file) + genesets = batch.process_lines(contents) + + # TODO: Remove this print statement. + for geneset in genesets: + print(geneset, "\n") + + # TODO: Add the genesets to the database + # results = [ + # batch_geneset_for_user(db, user_id, geneset) + # for geneset in genesets + # ] + + # TODO: Return the correct values. + return [10], [], [] diff --git a/src/geneweaver/api/services/pubmeds.py b/src/geneweaver/api/services/pubmeds.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/unit/__init__.py b/tests/api/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/unit/services/__init__.py b/tests/api/unit/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/unit/services/io/__init__.py b/tests/api/unit/services/io/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/unit/services/io/test_read_file_contents.py b/tests/api/unit/services/io/test_read_file_contents.py new file mode 100644 index 0000000..e5a6042 --- /dev/null +++ b/tests/api/unit/services/io/test_read_file_contents.py @@ -0,0 +1,86 @@ +""""Unit tests for the read_file_contents function in the io module.""" +import pytest +from geneweaver.api.services import io + + +@pytest.mark.asyncio() +@pytest.mark.parametrize( + ("contents", "encoding", "expected"), + [ + # normal UTF-8 encoded text + (b"Hello, world!", "utf-8", "Hello, world!"), + # improperly encoded UTF-8 text + (b"\x80abc", "utf-8", UnicodeDecodeError), + # ASCII encoded text + (b"Hello, world!", "ascii", "Hello, world!"), + # Empty file + (b"", "utf-8", ""), + # Non-English characters (Japanese, in this case) + ( + b"\xe3\x81\x93\xe3\x82\x93\xe3\x81\xab\xe3\x81\xa1\xe3" + b"\x81\xaf\xe3\x80\x81\xe4\xb8\x96\xe7\x95\x8c!", + "utf-8", + "こんにちは、世界!", + ), + # Non-English characters (Arabic, in this case) + ( + b"\xd9\x85\xd8\xb1\xd8\xad\xd8\xa8\xd8\xa7\xd8\x8c " + b"\xd8\xa7\xd9\x84\xd8\xb9\xd8\xa7\xd9\x84\xd9\x85!", + "utf-8", + "مرحبا، العالم!", + ), + # Non-English characters (Greek, in this case) + ( + b"\xce\x93\xce\xb5\xce\xb9\xce\xac \xcf\x83\xce\xbf\xcf\x85, " + b"\xce\xba\xcf\x8c\xcf\x83\xce\xbc\xce\xbf!", + "utf-8", + "Γειά σου, κόσμο!", + ), + # Non-English characters (Hindi, in this case) + ( + b"\xe0\xa4\xa8\xe0\xa4\xae\xe0\xa4\xb8" + b"\xe0\xa5\x8d\xe0\xa4\xa4\xe0\xa5\x87, " + b"\xe0\xa4\xa6\xe0\xa5\x81\xe0\xa4\xa8" + b"\xe0\xa4\xbf\xe0\xa4\xaf\xe0\xa4\xbe!", + "utf-8", + "नमस्ते, दुनिया!", + ), + # Non-English characters (Hebrew, in this case) + ( + b"\xd7\xa9\xd7\x9c\xd7\x95\xd7\x9d, \xd7\xa2\xd7\x95\xd7\x9c\xd7\x9d!", + "utf-8", + "שלום, עולם!", + ), + # Unicode characters + (b"\xe2\x9c\x88 World!", "utf-8", "✈ World!"), + # Different encoding (ISO-8859-1) + (b"Hello, world!", "ISO-8859-1", "Hello, world!"), + # Improperly encoded text + (b"\x80\xe2\x82\xac", "utf-8", UnicodeDecodeError), + # Attempt to decode byte sequence not valid in UTF-8 + (b"Hello, world!", "unknown_encoding", LookupError), + # Use of an unknown encoding + (b"\x80abc", "ascii", UnicodeDecodeError), + # Attempt to decode byte sequence not valid in ASCII + ( + b"\xd9\x85\xd8\xb1\xd8\xad\xd8\xa8\xd8\xa7\xd8\x8c " + b"\xd8\xa7\xd9\x84\xd8\xb9\xd8\xa7\xd9\x84\xd9\x85!", + "ascii", + UnicodeDecodeError, + ), + # Attempt to decode non-ASCII byte sequence with ASCII encoding + ], +) +async def test_read_file_contents(contents, encoding, expected, mock_upload_file): + # Set up the mock to return the test contents when read + mock_upload_file.read.return_value = contents + + if expected is UnicodeDecodeError or expected is LookupError: + # If the test case expects a UnicodeDecodeError, check if it raises + with pytest.raises(expected): + await io.read_file_contents(mock_upload_file, encoding) + else: + # Otherwise, just assert the expected and actual outputs match + assert await io.read_file_contents(mock_upload_file, encoding) == expected + + assert mock_upload_file.read.called diff --git a/tests/api/unit/services/parse/__init__.py b/tests/api/unit/services/parse/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/unit/services/parse/batch/__init__.py b/tests/api/unit/services/parse/batch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/unit/services/parse/batch/conftest.py b/tests/api/unit/services/parse/batch/conftest.py new file mode 100644 index 0000000..4234ad0 --- /dev/null +++ b/tests/api/unit/services/parse/batch/conftest.py @@ -0,0 +1,26 @@ +from unittest.mock import AsyncMock + +import pytest +from fastapi import UploadFile + +from tests.api.unit.services.parse.batch import const + + +# Create a pytest fixture for the mocked UploadFile +@pytest.fixture() +def mock_upload_file(): + mock_file = AsyncMock(spec=UploadFile) + return mock_file # provide the mock object to the test + + +@pytest.fixture( + params=[ + const.EXAMPLE_BATCH_FILE, + "\n".join(const.EXAMPLE_BATCH_FILE.splitlines()[:124]), + "\r".join(const.EXAMPLE_BATCH_FILE.splitlines()[:124]), + "\n".join(const.EXAMPLE_BATCH_FILE.splitlines()[:309]), + "\r".join(const.EXAMPLE_BATCH_FILE.splitlines()[:309]), + ] +) +def example_batch_file_contents(request) -> str: + return request.param diff --git a/tests/api/unit/services/parse/batch/const.py b/tests/api/unit/services/parse/batch/const.py new file mode 100644 index 0000000..54684a0 --- /dev/null +++ b/tests/api/unit/services/parse/batch/const.py @@ -0,0 +1,401 @@ +# ruff: noqa: E501 +EXAMPLE_BATCH_FILE = """ +# This is an example batch upload file for GeneWeaver. +# http://geneweaver.org +# +# The format of this file is described online at: +# http://geneweaver.org/index.php?action=manage&cmd=batchgeneset +# Which is the same page that it can be submitted to. +# +# The next 5 lines apply to all the sets in this file, so they +# are re-used on upload instead of repeated in this file. If another gene set +# further in the batch file uses a different p-value threshold or species +# for example, these lines can be changed later on in the file. +# + +! P-Value < 0.001 +@ Mus musculus +% microarray Mouse Expression Array 430 Set +P 19958391 +A Private + +# +# The following lines give the label, name, and description of +# this set, respectively. +# + +: STR ACTI_DIFF_05 F BXD M430v2 RMA += Striatum Gene expression correlates of Difference in distance traveled (cm) during the first five min (saline-ethanol) in Females BXD ++ Striatum Gene Expression Correlates for ACTI_DIFF_05 measured in BXD RI Females obtained using GeneNetwork Striatum M430V2 (Apr05) RMA. ++ The ACTI_DIFF_05 measures Difference in distance traveled (cm) during the first five min (saline-ethanol) under the domain Ethanol. ++ The correlates were thresholded at a p-value of less than 0.001. + +# +# Probe/gene data starts after all the gene set metadata has been specified. +# + +1419895_at 4.62954E-05 +1427431_at 5.21582E-05 +1459099_at 5.49844E-05 +1439831_at 6.81706E-05 +1450206_at 7.24209E-05 +1443550_at 7.74979E-05 +1429056_at 8.55707E-05 +1443615_at 9.26147E-05 +1454179_at 0.000108698 +1425459_at 0.000126946 +1417874_at 0.000127752 +1418440_at 0.000143051 +1459437_at 0.000147362 +1423335_at 0.000182977 +1426146_a_at 0.000207811 +1426237_at 0.000208478 +1453826_at 0.000223366 +1418472_at 0.000225267 +1432515_at 0.000236315 +1458208_s_at 0.000251702 +1449365_at 0.000265399 +1427614_at 0.000271715 +1438721_a_at 0.000274974 +1451106_at 0.000299124 +1434317_s_at 0.00031313 +1455170_at 0.000330622 +1455782_at 0.000344905 +1429428_at 0.000347363 +1433781_a_at 0.000361397 +1450522_a_at 0.000363282 +1442297_at 0.000400537 +1426960_a_at 0.000401115 +1415673_at 0.000408955 +1417014_at 0.00040993 +1443832_s_at 0.00042933 +1452045_at 0.000430262 +1424823_s_at 0.00043663 +1433939_at 0.000443502 +1431168_at 0.000455925 +1421270_at 0.00046495 +1454067_a_at 0.000470904 +1446485_at 0.00047431 +1422757_at 0.000482093 +1457240_at 0.000501097 +1422306_at 0.000514778 +1458362_at 0.000532965 +1424315_at 0.000566445 +1428568_at 0.000567672 +1448948_at 0.000569434 +1416081_at 0.000578466 +1422634_a_at 0.00058238 +1453616_at 0.000583529 +1431667_s_at 0.000599767 +1425234_at 0.000610597 +1442013_at 0.000632513 +1418041_at 0.000653667 +1425153_at 0.000661747 +1423518_at 0.00070306 +1458854_at 0.000704167 +1434701_at 0.000704734 +1452517_at 0.000715244 +1429595_at 0.000719228 +1459327_at 0.000738269 +1426467_s_at 0.000740976 +1417309_at 0.000744451 +1423770_at 0.000757701 +1431886_at 0.000766493 +1419289_a_at 0.000768622 +1437208_at 0.000782469 +1435627_x_at 0.000782591 +1435465_at 0.000793171 +1416149_at 0.000810041 +1433630_at 0.000819244 +1442889_at 0.000836545 +1453687_at 0.00083677 +1423109_s_at 0.000838646 +1446050_at 0.000841425 +1456717_at 0.000868755 +1423382_a_at 0.000876785 +1453709_at 0.000891731 +1456370_s_at 0.000902406 +1427944_at 0.000920491 +1436508_at 0.000923192 +1432024_at 0.000947824 +1459710_at 0.000966139 +1430370_at 0.000995187 +1415864_at 0.000996862 +1428483_a_at 0.000999938 + +# When gene set metadata symbols are encountered again, this signifies the end +# of the first gene set. The second data set follows, then other sets, until +# the end of the file is reached. +# + +: STR ACTI_DIFF_05 M BXD M430v2 RMA += Striatum Gene expression correlates of Difference in distance traveled (cm) during the first five min (saline-ethanol) in Males BXD ++ Striatum Gene Expression Correlates for ACTI_DIFF_05 measured in BXD RI Males obtained using GeneNetwork Striatum M430V2 (Apr05) RMA. ++ The ACTI_DIFF_05 measures Difference in distance traveled (cm) during the first five min (saline-ethanol) under the domain Ethanol. ++ The correlates were thresholded at a p-value of less than 0.001. + +1460595_at 3.04127e-007 +1455130_at 1.45388e-006 +1424390_at 4.27088e-005 +1426826_at 5.08465e-005 +1420611_at 6.22965e-005 +1416320_at 6.81489e-005 +1424243_at 7.09202e-005 +1460389_at 7.24103e-005 +1428891_at 8.45653e-005 +1460573_at 8.8657e-005 +1428466_at 8.95255e-005 +1415746_at 9.17271e-005 +1434264_at 9.18051e-005 +1436412_at 0.000106824 +1443924_at 0.000125556 +1432061_at 0.000133211 +1453119_at 0.000134048 +1430062_at 0.000134412 +1434384_at 0.000144186 +1415689_s_at 0.00014714 +1451436_at 0.000149564 +1452942_at 0.000151914 +1432625_at 0.00017943 +1436498_at 0.000183371 +1415729_at 0.000186867 +1456283_at 0.000188729 +1453453_at 0.000188834 +1415769_at 0.000188969 +1448809_at 0.000190696 +1459920_at 0.000191884 +1435818_at 0.00019201 +1421305_x_at 0.000194441 +1419228_at 0.000194813 +1418433_at 0.00019772 +1454675_at 0.000207191 +1427947_at 0.000209615 +1415863_at 0.000210644 +1448083_at 0.000216645 +1429476_s_at 0.000216702 +1459145_at 0.000217422 +1441973_at 0.000218309 +1420478_at 0.000218961 +1423613_at 0.000223316 +1426933_at 0.000245662 +1438428_at 0.000263381 +1457939_at 0.00026804 +1450700_at 0.000271212 +1460726_at 0.000273019 +1415834_at 0.000281249 +1454222_a_at 0.000285377 +1425580_a_at 0.000286385 +1454827_at 0.000290301 +1434251_at 0.000297939 +1460615_at 0.00030438 +1444960_at 0.00031556 +1415887_at 0.000317876 +1431822_a_at 0.000321428 +1451324_s_at 0.000323631 +1429013_at 0.000324283 +1434917_at 0.000331919 +1460440_at 0.000332755 +1426218_at 0.000334486 +1432464_a_at 0.000349886 +1437148_at 0.000350493 +1456199_x_at 0.000352374 +1420922_at 0.000354241 +1417763_at 0.000354706 +1423369_at 0.000356982 +1450655_at 0.000359364 +1429451_at 0.00036425 +1436804_s_at 0.000402476 +1435360_at 0.000408271 +1428949_at 0.000416803 +1439350_s_at 0.000421143 +1455400_at 0.000425877 +1433658_x_at 0.00043765 +1454763_at 0.00043949 +1428648_at 0.000442941 +1429399_at 0.000444247 +1423126_at 0.000452955 +1452075_at 0.000453909 +1420882_a_at 0.000459761 +1434016_at 0.000465769 +1452970_at 0.000471097 +1455418_at 0.000476002 +1436307_at 0.000476775 +1431394_a_at 0.000477686 +1424530_at 0.000480735 +1429678_at 0.000483195 +1418543_s_at 0.00048529 +1424135_at 0.00048669 +1456717_at 0.000490375 +1442614_at 0.000502168 +1460620_at 0.00050726 +1436150_at 0.000514935 +1433649_at 0.000525362 +1433975_at 0.000532959 +1439024_at 0.000537926 +1450882_s_at 0.000538512 +1436448_a_at 0.000552269 +1455508_at 0.000566657 +1436101_at 0.000569536 +1428388_at 0.000573968 +1428138_s_at 0.000574887 +1422714_at 0.000588041 +1433811_at 0.000590484 +1418706_at 0.000608581 +1433770_at 0.00061608 +1455094_s_at 0.00061877 +1454994_at 0.000619628 +1422508_at 0.0006251 +1428097_at 0.000630794 +1450755_at 0.000637877 +1436027_at 0.000638305 +1443742_x_at 0.00064035 +1458991_at 0.000656151 +1416369_at 0.000658738 +1434738_at 0.000666963 +1433664_at 0.000672556 +1455166_at 0.00067343 +1442649_at 0.000678125 +1452204_at 0.000684785 +1428307_at 0.000686815 +1444500_at 0.000689908 +1424597_at 0.000690582 +1459429_at 0.000695537 +1428531_at 0.000700511 +1416029_at 0.00070569 +1427109_at 0.000710559 +1428457_at 0.000711185 +1441136_at 0.000715173 +1433555_at 0.000717655 +1433879_a_at 0.000729475 +1424326_at 0.000734742 +1417847_at 0.000737301 +1433632_at 0.000739978 +1453290_at 0.000740166 +1429910_at 0.00076537 +1456482_at 0.000765382 +1453028_at 0.000775823 +1435916_at 0.000779491 +1448706_at 0.000785693 +1423304_a_at 0.000796173 +1423821_at 0.000806645 +1426840_at 0.000819303 +1418505_at 0.000824724 +1456798_at 0.000830067 +1455862_at 0.00083365 +1424532_at 0.000852561 +1428819_at 0.000852929 +1434078_at 0.000860667 +1426342_at 0.000868494 +1428796_at 0.000880571 +1418521_a_at 0.000886687 +1457811_at 0.000893002 +1437464_at 0.000893639 +1420654_a_at 0.000896139 +1435637_at 0.000898089 +1428883_at 0.000909877 +1435291_at 0.000913222 +1429473_at 0.000920155 +1415684_at 0.000920999 +1452363_a_at 0.000922349 +1422736_at 0.000922756 +1458629_at 0.000928507 +1452627_at 0.000934148 +1428122_s_at 0.000943615 +1436791_at 0.000950172 +1427317_at 0.000953463 +1434843_at 0.000977116 +1422315_x_at 0.000984641 +1444576_at 0.000991444 + + +: STR ACTI05_ETHA M BXD M430v2 RMA += Striatum Gene expression correlates of Distance traveled (cm) during the first five minutes after ethanol in Males BXD ++ Striatum Gene Expression Correlates for ACTI05_ETHA measured in BXD RI Males obtained using GeneNetwork Striatum M430V2 (Apr05) RMA. ++ The ACTI05_ETHA measures Distance traveled (cm) during the first five minutes after ethanol under the domain Ethanol. ++ The correlates were thresholded at a p-value of less than 0.001. + +1459127_at 3.89433e-005 +1453976_at 5.85397e-005 +1446104_at 6.81255e-005 +1418588_at 0.000105218 +1447469_at 0.000142648 +1428891_at 0.000150707 +1429743_at 0.000170647 +1444674_at 0.000244271 +1428890_at 0.000257403 +1444500_at 0.000267276 +1428487_s_at 0.000317112 +1460615_at 0.000328649 +1450618_a_at 0.000350512 +1446717_at 0.000372691 +1421213_at 0.000374089 +1421235_s_at 0.000426869 +1433927_at 0.000448532 +1419228_at 0.000489634 +1425378_at 0.000491551 +1453453_at 0.000552417 +1431717_at 0.00057804 +1459324_at 0.000648819 +1425111_at 0.000675884 +1418041_at 0.000732076 +1447313_at 0.000738001 +1452045_at 0.000772274 +1430724_at 0.000844233 +1450348_at 0.000872906 +1460011_at 0.000884376 +1452699_at 0.000958429 +1420710_at 0.000994726 + +! Binary +% Gene Symbol +P 21223303 +A Private + +: Transcripts enriched in blood += Transcripts enriched in blood of C57BL/6J mice drinking to intoxication. ++ Transcripts enriched more than 50 fold in blood of C57BL/6J mice drinking to intoxication with the fold enrichment. + +Alas2 1 +Car2 1 +Cd24a 1 +Ctla2a 1 +Ctla2b 1 +Epb4.1 1 +Fech 1 +Got2 1 +Hba-a1 1 +Hba-x 1 +Hbb-b1 1 +Pam 1 +Ptpn13 1 +Nfkbia 1 +Gpx1 1 +Dusp8 1 +Ucp2 1 +Bpgm 1 +Slc25a39 1 +Cd200 1 +Atp6v0d1 1 +Pik3c2a 1 +Snca 1 +Bnip3l 1 +Ncoa4 1 +Tcl1b1 1 +Mkrn1 1 +Ppbp 1 +Gng11 1 +Cryzl1 1 +9130011J15Rik 1 +Ube2l6 1 +Slc25a37 1 +Hnrnpul2 1 +Isca1 1 +Srsf11 1 +Mex3a 1 +Fbxw8 1 +Sun1 1 +Htra2 1 +Rpap2 1 +Rhbdd3 1 +Prss35 1 +""" diff --git a/tests/api/unit/services/parse/batch/test_parse.py b/tests/api/unit/services/parse/batch/test_parse.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/db/__init__.py b/tests/db/__init__.py new file mode 100644 index 0000000..e69de29