Skip to content

Commit 5732035

Browse files
Feat: TARS refactor (#2)
* init commit * tests for tars * cleanup * remove 404 errors * Feat init commit update (#3) * feat: uses async client * fix: return limit --------- Co-authored-by: jtyoung84 <104453205+jtyoung84@users.noreply.github.com>
1 parent ccdd8ea commit 5732035

24 files changed

Lines changed: 1430 additions & 246 deletions

README.md

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,17 @@
1-
# aind-service-template
2-
A template repo that can be used for rapid development of FastAPI services.
1+
# aind-tars-service
2+
REST service to connect to TARS and return information.
33

44
## Usage
55

6-
### Initialize a repo
6+
### Create the server.
77

8-
- Click the green "Use This Template" button
9-
- Once the repo is created, please wait several seconds for the github action to initialize the files. You can check the status in the "Actions" tab.
10-
- Update the main README and *-server/README files if desired
11-
- It's recommended to update the repo settings to require Pull Requests and Reviews.
8+
- The server code is developed in the aind-tars-service-server package.
9+
- On a push to main, a docker image will be built and published to GitHub's container registry.
10+
- You can then run the server in a docker container or k8s pod.
11+
- More details can be found in the [service README](aind-tars-service-server/README.md) file
1212

13-
### Auto-run tests, publish docker image, and create client code
13+
### Auto-generated client code.
1414

15-
- In the repo settings, add `svc-aindscicomp` as an admin to the list of Collaborators.
16-
- Move the yaml files from `workflow_templates` to `.github/workflows/`
17-
- When a PR is opened, the server code will be tested.
18-
- When a PR is merged into main:
19-
- The tag will be incremented
20-
- The server code will be published as a docker image
21-
- The client code will be autogenerated from the openapi specification and published to PyPI
15+
- The client code is autogenerated using an openapi generator.
16+
- On a push to main, a python library will be built and published to PyPI.
17+
- The client can then be pip installed as `pip install aind-tars-service-client`

aind-tars-service-server/README.md

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,78 @@
1-
# aind-service-template-server
1+
# aind-tars-service-server
22

33
[![License](https://img.shields.io/badge/license-MIT-brightgreen)](LICENSE)
44
![Code Style](https://img.shields.io/badge/code%20style-black-black)
5-
[![semantic-release: angular](https://img.shields.io/badge/semantic--release-angular-e10079?logo=semantic-release)](https://github.com/semantic-release/semantic-release)
6-
![Interrogate](https://img.shields.io/badge/interrogate-100.0%25-brightgreen)
5+
@@ -7,5 +7,74 @@
76
![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen?logo=codecov)
87
![Python](https://img.shields.io/badge/python->=3.10-blue?logo=python)
98

10-
REST service to retrieve data from an backend.
9+
REST service to retrieve data from TARS.
1110

11+
## Local Development
12+
13+
Requires docker to build and run package locally.
14+
15+
- Create a file called env/webapp.env with appropriate env variables.
16+
- Run `docker build -t aind-tars-service-server-local:latest .`
17+
- Run `docker run -p 5000:80 --env-file=env/webapp.env aind-tars-service-server-local:latest`
18+
- Service will be available at `http://localhost:5000`
19+
- Check docs at `http://localhost:5000/docs`
20+
21+
### Linters and testing
22+
23+
There are several libraries used to run linters, check documentation, and run
24+
tests.
25+
26+
- Please test your changes using the **coverage** library, which will run the
27+
tests and log a coverage report:
28+
29+
```
30+
coverage run -m pytest && coverage report
31+
```
32+
33+
- Use **interrogate** to check that modules, methods, etc. have been documented
34+
thoroughly:
35+
36+
```
37+
interrogate .
38+
```
39+
40+
- Use **flake8** to check that code is up to standards
41+
(no unused imports, etc.):
42+
```
43+
flake8 .
44+
```
45+
46+
- Use **black** to automatically format the code into PEP standards:
47+
```
48+
black .
49+
```
50+
51+
- Use **isort** to automatically sort import statements:
52+
```
53+
isort .
54+
```
55+
56+
### Pull requests
57+
58+
For internal members, please create a branch. For external members, please fork
59+
the repo and open a pull request from the fork. We'll primarily use
60+
[Angular](https://github.com/angular/angular/blob/main/CONTRIBUTING.md#commit)
61+
style for commit messages. Roughly, they should follow the pattern:
62+
```
63+
<type>(<scope>): <short summary>
64+
```
65+
66+
where scope (optional) describes the packages affected by the code changes and
67+
type (mandatory) is one of:
68+
69+
- **build**: Changes that affect the build system or external dependencies
70+
(example scopes: pyproject.toml, setup.py)
71+
- **ci**: Changes to our CI configuration files and scripts
72+
(examples: .github/workflows/ci.yml)
73+
- **docs**: Documentation only changes
74+
- **feat**: A new feature
75+
- **fix**: A bug fix
76+
- **perf**: A code change that improves performance
77+
- **refactor**: A code change that neither fixes a bug nor adds a feature
78+
- **test**: Adding missing tests or correcting existing tests

aind-tars-service-server/pyproject.toml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ readme = "README.md"
1717
dynamic = ["version"]
1818

1919
dependencies = [
20-
'requests-toolbelt', # Used for example. Remove if not needed.
2120
'pydantic>=2.0',
2221
'pydantic-settings>=2.0',
2322
'fastapi[standard]>=0.114.0',
23+
'azure-identity>=1.15.0',
24+
'fastapi-cache2[redis]>=0.2.2',
25+
'httpx'
2426
]
2527

2628
[project.optional-dependencies]
@@ -90,5 +92,9 @@ fail-under = 100
9092
asyncio_mode="auto"
9193
asyncio_default_fixture_loop_scope="function"
9294
env = [
93-
"MYENV_HOST=example"
95+
"TARS_TENANT_ID=tenant_id",
96+
"TARS_CLIENT_ID=client_id",
97+
"TARS_CLIENT_SECRET=client_secret",
98+
"TARS_SCOPE=http://example.com/scope",
99+
"TARS_RESOURCE=http://example.com/resource"
94100
]
Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,62 @@
1-
"""Module for settings to connect to backend"""
1+
"""Module for settings to connect to TARS backend"""
22

3-
from pydantic import Field
3+
from typing import Optional
4+
from urllib.parse import urljoin
5+
6+
from pydantic import Field, HttpUrl, RedisDsn, SecretStr
47
from pydantic_settings import BaseSettings, SettingsConfigDict
58

69

710
class Settings(BaseSettings):
8-
"""
9-
### Settings needed to connect to a database or website.
10-
We will just connect to an example website.
11-
"""
12-
13-
model_config = SettingsConfigDict(env_prefix="MYENV_")
14-
host: str = Field(
15-
...,
16-
title="Host",
17-
description="Host address of example.com",
11+
"""Settings needed to connect to TARS."""
12+
13+
# noinspection SpellCheckingInspection
14+
model_config = SettingsConfigDict(env_prefix="TARS_")
15+
tenant_id: str = Field(..., description="Azure tenant id.")
16+
client_id: str = Field(
17+
..., description="Client ID of the service account accessing resource."
1818
)
19+
client_secret: SecretStr = Field(
20+
..., description="Secret used to access the account."
21+
)
22+
scope: str = Field(..., description="Scope")
23+
resource: HttpUrl = Field(..., description="Resource")
24+
redis_url: Optional[RedisDsn] = Field(default=None)
25+
26+
@property
27+
def viral_prep_lots_url(self) -> HttpUrl:
28+
"""URL for ViralPrepLots"""
29+
return HttpUrl.build(
30+
scheme=self.resource.scheme,
31+
host=self.resource.host,
32+
path=urljoin(
33+
f"{self.resource.path.lstrip('/')}/", "api/v1/ViralPrepLots"
34+
).lstrip("/"),
35+
)
36+
37+
@property
38+
def viruses_url(self) -> HttpUrl:
39+
"""URL for Viruses"""
40+
return HttpUrl.build(
41+
scheme=self.resource.scheme,
42+
host=self.resource.host,
43+
path=urljoin(
44+
f"{self.resource.path.lstrip('/')}/", "api/v1/Viruses"
45+
).lstrip("/"),
46+
)
47+
48+
@property
49+
def molecules_url(self) -> HttpUrl:
50+
"""URL for Molecules"""
51+
return HttpUrl.build(
52+
scheme=self.resource.scheme,
53+
host=self.resource.host,
54+
path=urljoin(
55+
f"{self.resource.path.lstrip('/')}/", "api/v1/Molecules"
56+
).lstrip("/"),
57+
)
58+
59+
60+
def get_settings() -> Settings:
61+
"""Utility method to return a settings object."""
62+
return Settings()
Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,60 @@
1-
"""Module to retrieve data from a backend using a session object"""
1+
"""Module to retrieve data from TARS using session object"""
22

3-
import logging
3+
from copy import deepcopy
4+
from typing import Any, Dict, List, Optional
45

5-
from requests_toolbelt.sessions import BaseUrlSession
6-
7-
from aind_tars_service_server.models import Content
6+
from httpx import AsyncClient
87

98

109
class SessionHandler:
11-
"""Handle session object to get data"""
10+
"""Handle pulling data from Sharepoint lists"""
1211

13-
def __init__(self, session: BaseUrlSession):
14-
"""Class constructor"""
15-
self.session = session
12+
def __init__(self, client: AsyncClient):
13+
"""
14+
Class constructor.
15+
Parameters
16+
----------
17+
client : AsyncClient
18+
"""
19+
self.client = client
1620

17-
def get_info(self, example_arg: str) -> Content:
21+
async def get_data(
22+
self, url: str, params: Optional[Dict[str, str]], limit: int = 0
23+
) -> List[Dict[str, Any]]:
1824
"""
19-
Get information from a backend. An example argument is added.
25+
Returns data from TARS.
2026
2127
Parameters
2228
----------
23-
example_arg : str
29+
url : str
30+
params : Dict[str, str] | None
31+
limit : int
32+
Limit number of items returned. Set to 0 to return all.
2433
2534
Returns
2635
-------
27-
str
28-
Contents of a webpage.
36+
List[Dict[str, Any]]
2937
3038
"""
3139

32-
logging.debug(f"Sending request for {example_arg}")
33-
response = self.session.get("")
40+
params_copy = {"page": "0"} if params is None else deepcopy(params)
41+
response = await self.client.get(url=url, params=params_copy)
3442
response.raise_for_status()
35-
logging.debug(f"Received response for {example_arg}")
36-
text = response.text
37-
if example_arg == "length":
38-
return Content(info=str(len(text)), arg=example_arg)
39-
else:
40-
return Content(info=text, arg=example_arg)
43+
all_data = []
44+
more_pages = response.json()["morePages"]
45+
total_count = response.json()["totalCount"]
46+
page_num = response.json()["page"]
47+
all_data.extend(response.json()["data"])
48+
number_of_records = len(all_data)
49+
max_count = total_count if limit == 0 else min(total_count, limit)
50+
while more_pages and number_of_records < max_count:
51+
params_copy["page"] = page_num + 1
52+
response = await self.client.get(url=url, params=params_copy)
53+
response.raise_for_status()
54+
more_pages = response.json()["morePages"]
55+
page_num = response.json()["page"]
56+
all_data.extend(response.json()["data"])
57+
number_of_records = len(all_data)
58+
if limit > 0:
59+
all_data = all_data[0:limit]
60+
return all_data

aind-tars-service-server/src/aind_tars_service_server/main.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,54 @@
22

33
import logging
44
import os
5+
from collections.abc import AsyncIterator
6+
from contextlib import asynccontextmanager
57

68
from fastapi import FastAPI
79
from fastapi.middleware.cors import CORSMiddleware
810
from fastapi.routing import APIRoute
11+
from fastapi_cache import FastAPICache
12+
from fastapi_cache.backends.inmemory import InMemoryBackend
13+
from fastapi_cache.backends.redis import RedisBackend
14+
15+
# from redis import asyncio as aioredis # noqa
16+
from redis.asyncio import from_url # noqa
917

1018
from aind_tars_service_server import __version__ as service_version
19+
from aind_tars_service_server.configs import get_settings
1120
from aind_tars_service_server.route import router
1221

1322
# The log level can be set by adding an environment variable before launch.
1423
log_level = os.getenv("LOG_LEVEL", "INFO")
1524
logging.basicConfig(level=log_level)
1625

1726
description = """
18-
## aind-service-template
27+
## aind-tars-service
1928
20-
Service to pull data from example backend.
29+
Service to pull data from TARS.
2130
2231
"""
2332

33+
34+
@asynccontextmanager
35+
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
36+
"""Init cache and add to lifespan of app"""
37+
settings = get_settings()
38+
if settings.redis_url is not None:
39+
redis = from_url(settings.redis_url)
40+
FastAPICache.init(RedisBackend(redis), prefix="fastapi-cache")
41+
else:
42+
FastAPICache.init(InMemoryBackend(), prefix="fastapi-cache")
43+
yield
44+
45+
2446
# noinspection PyTypeChecker
2547
app = FastAPI(
26-
title="aind-service-template",
48+
title="aind-tars-service",
2749
description=description,
28-
summary="Serves data from example backend.",
50+
summary="Serves data from TARS.",
2951
version=service_version,
52+
lifespan=lifespan,
3053
)
3154

3255
# noinspection PyTypeChecker

0 commit comments

Comments
 (0)