Skip to content

Commit

Permalink
style: fix lint complaints and add docstrings
Browse files Browse the repository at this point in the history
  • Loading branch information
patricklatimer committed Jun 5, 2024
1 parent bce6796 commit 1f83168
Show file tree
Hide file tree
Showing 9 changed files with 155 additions and 73 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ venv/
ENV/
env.bak/
venv.bak/
.conda

# Spyder project settings
.spyderproject
Expand Down
5 changes: 3 additions & 2 deletions src/aind_slims_api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""Init package"""

from .core import SlimsClient # noqa


__version__ = "0.0.0"

from .configuration import AindSlimsApiSettings

config = AindSlimsApiSettings()

from .core import SlimsClient
7 changes: 4 additions & 3 deletions src/aind_slims_api/configuration.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from typing import Optional
""" Library Configuration model """

from pydantic import HttpUrl, SecretStr
from pydantic import SecretStr
from pydantic_settings import BaseSettings


class AindSlimsApiSettings(BaseSettings):
"""Settings for SLIMS Client
Per pydantic-settings docs https://docs.pydantic.dev/latest/concepts/pydantic_settings/
Per pydantic-settings docs
https://docs.pydantic.dev/latest/concepts/pydantic_settings/
Loads slims credentials from environment variables if present"""

slims_url: str = "https://aind-test.us.slims.agilent.com/slimsrest/"
Expand Down
90 changes: 63 additions & 27 deletions src/aind_slims_api/core.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,24 @@
from datetime import timezone as tz
import datetime
"""Contents:
Utilities for creating pydantic models for SLIMS data:
SlimsBaseModel - to be subclassed for SLIMS pydantic models
UnitSpec - To be included in a type annotation of a Quantity field
SlimsClient - Basic wrapper around slims-python-api client with convenience
methods and integration with SlimsBaseModel subtypes
"""

from datetime import datetime
from enum import Enum
from functools import lru_cache
import os
import json
from dataclasses import dataclass
from zoneinfo import ZoneInfo
from pydantic import (
BaseModel,
BeforeValidator,
ValidationError,
ValidationInfo,
conlist,
field_serializer,
field_validator,
model_validator,
)
from pydantic.fields import FieldInfo
import requests
from requests.auth import HTTPBasicAuth
import logging
from typing import Annotated, Any, Self, Union, Literal, Optional
import math
from typing import Literal, Optional

from slims.slims import Slims, _SlimsApiException
from slims.internal import (
Expand All @@ -49,18 +45,22 @@


class UnitSpec:
"""Used in type annotation metadata to specify units"""

units: list[str]
preferred_unit: str = None

def __init__(self, *args, preferred_unit=None):
"""Set list of acceptable units from args, and preferred_unit"""
self.units = args
if len(self.units) == 0:
raise ValueError("One or more units must be specified")
if preferred_unit is None:
self.preferred_unit = self.units[0]


def find_unit_spec(field: FieldInfo) -> UnitSpec | None:
def _find_unit_spec(field: FieldInfo) -> UnitSpec | None:
"""Given a Pydantic FieldInfo, find the UnitSpec in its metadata"""
metadata = field.metadata
for m in metadata:
if isinstance(m, UnitSpec):
Expand Down Expand Up @@ -90,29 +90,36 @@ class MyModel(SlimsBaseModel):
_slims_table: SLIMSTABLES

@field_validator("*", mode="before")
def validate(cls, value, info: ValidationInfo):
def _validate(cls, value, info: ValidationInfo):
"""Validates a field, accounts for Quantities"""
if isinstance(value, SlimsColumn):
if value.datatype == "QUANTITY":
unit_spec = find_unit_spec(cls.model_fields[info.field_name])
unit_spec = _find_unit_spec(cls.model_fields[info.field_name])
if unit_spec is None:
msg = f'Quantity field "{info.field_name}" must be annotated with a UnitSpec'
msg = (
f'Quantity field "{info.field_name}"'
"must be annotated with a UnitSpec"
)
raise TypeError(msg)
if value.unit not in unit_spec.units:
msg = f'Unexpected unit "{value.unit}" for field {info.field_name}, Expected {unit_spec.units}'
msg = (
f'Unexpected unit "{value.unit}" for field '
f"{info.field_name}, Expected {unit_spec.units}"
)
raise ValueError(msg)
return value.value
else:
return value

@field_serializer("*")
def serialize(self, field, info):
unit_spec = find_unit_spec(self.model_fields[info.field_name])
def _serialize(self, field, info):
"""Serialize a field, accounts for Quantities and datetime"""
unit_spec = _find_unit_spec(self.model_fields[info.field_name])
if unit_spec and field is not None:
quantity = {
"amount": field,
"unit_display": unit_spec.preferred_unit,
}
# quantity["unit_pk"] = 6 if unit_spec.preferred_unit == "g" else 15
return quantity
elif isinstance(field, datetime):
return int(field.timestamp() * 10**3)
Expand All @@ -125,7 +132,10 @@ def serialize(self, field, info):


class SlimsClient:
"""Wrapper around slims-python-api client with convenience methods"""

def __init__(self, url=None, username=None, password=None):
"""Create object and try to connect to database"""
self.url = url or config.slims_url
self.db: Slims = None

Expand All @@ -136,6 +146,7 @@ def __init__(self, url=None, username=None, password=None):
)

def connect(self, url: str, username: str, password: str):
"""Connect to the database"""
self.db = Slims(
"slims",
url,
Expand Down Expand Up @@ -186,26 +197,27 @@ def fetch(

return records

def fetch_unit(self, unit_name: str) -> SlimsRecord:
return self.fetch("Unit", unit_name=unit_name)[0]

@lru_cache(maxsize=None)
def fetch_pk(self, table: SLIMSTABLES, *args, **kwargs) -> int | None:
"""SlimsClient.fetch but returns the pk of the first returned record"""
records = self.fetch(table, *args, **kwargs)
if len(records) > 0:
return records[0].pk()
else:
return None

def fetch_user(self, user_name: str):
"""Fetches a user by username"""
return self.fetch("User", user_userName=user_name)

def add(self, table: SLIMSTABLES, data: dict):
"""Add a SLIMS record to a given SLIMS table"""
record = self.db.add(table, data)
logger.info(f"SLIMS Add: {table}/{record.pk()}")
return record

def update(self, table: SLIMSTABLES, pk: int, data: dict):
"""Update a SLIMS record"""
record = self.db.fetch_by_pk(table, pk)
if record is None:
raise ValueError('No data in SLIMS "{table}" table for pk "{pk}"')
Expand All @@ -214,11 +226,24 @@ def update(self, table: SLIMSTABLES, pk: int, data: dict):
return new_record

def rest_link(self, table: SLIMSTABLES, **kwargs):
"""Construct a url link to a SLIMS table with arbitrary filters"""
base_url = f"{self.url}/rest/{table}"
queries = [f"?{k}={v}" for k, v in kwargs.items()]
return base_url + "".join(queries)

def add_model(self, model: SlimsBaseModel, *args, **kwargs):
def add_model(
self, model: SlimsBaseModel, *args, **kwargs
) -> SlimsBaseModel:
"""Given a SlimsBaseModel object, add it to SLIMS
Args
model (SlimsBaseModel): object to add
*args (str): fields to include in the serialization
**kwargs: passed to model.model_dump()
Returns
An instance of the same type of model, with data from
the resulting SLIMS record
"""
fields_to_include = set(args) or None
fields_to_exclude = set(kwargs.get("exclude", []))
fields_to_exclude.add("pk")
Expand All @@ -234,6 +259,17 @@ def add_model(self, model: SlimsBaseModel, *args, **kwargs):
return type(model).model_validate(rtn)

def update_model(self, model: SlimsBaseModel, *args, **kwargs):
"""Given a SlimsBaseModel object, update its (existing) SLIMS record
Args
model (SlimsBaseModel): object to update
*args (str): fields to include in the serialization
**kwargs: passed to model.model_dump()
Returns
An instance of the same type of model, with data from
the resulting SLIMS record
"""
fields_to_include = set(args) or None
rtn = self.update(
model._slims_table,
Expand Down
11 changes: 7 additions & 4 deletions src/aind_slims_api/mouse.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging
from typing import Annotated, Optional
"""Contains a model for the mouse content, and a method for fetching it"""

from slims.slims import Record
import logging
from typing import Annotated

from pydantic import Field, ValidationError

Expand All @@ -11,6 +11,8 @@


class SlimsMouseContent(SlimsBaseModel):
"""Model for an instance of the Mouse ContentType"""

baseline_weight_g: Annotated[float | None, UnitSpec("g")] = Field(
..., alias="cntn_cf_baselineWeight"
)
Expand Down Expand Up @@ -55,7 +57,8 @@ def fetch_mouse_content(
mouse_details = mice[0]
if len(mice) > 1:
logger.warning(
f"Warning, Multiple mice in SLIMS with barcode {mouse_name}, using pk={mouse_details.cntn_pk}"
f"Warning, Multiple mice in SLIMS with barcode "
f"{mouse_name}, using pk={mouse_details.cntn_pk}"
)
else:
logger.warning("Warning, Mouse not in SLIMS")
Expand Down
11 changes: 8 additions & 3 deletions src/aind_slims_api/user.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
"""Contains a model for a user, and a method for fetching it"""

import logging
from typing import Annotated, Optional
from typing import Optional

from pydantic import Field, ValidationError

from .core import SlimsBaseModel, SlimsClient, UnitSpec
from .core import SlimsBaseModel, SlimsClient

logger = logging.getLogger()


# TODO: Tighten this up once users are more commonly used
class SlimsUser(SlimsBaseModel):
"""Model for user information in SLIMS"""

username: str = Field(..., alias="user_userName")
first_name: Optional[str] = Field("", alias="user_firstName")
last_name: Optional[str] = Field("", alias="user_lastName")
Expand All @@ -34,7 +38,8 @@ def fetch_user(
user_details = users[0]
if len(users) > 1:
logger.warning(
f"Warning, Multiple users in SLIMS with username {users}, using pk={user_details.pk}"
f"Warning, Multiple users in SLIMS with "
f"username {users}, using pk={user_details.pk}"
)
else:
logger.warning("Warning, User not in SLIMS")
Expand Down
Loading

0 comments on commit 1f83168

Please sign in to comment.