浏览代码

initial commit

Radu Boncea 2 年之前
当前提交
0f251447c2

+ 228 - 0
.gitignore

@@ -0,0 +1,228 @@
+# Created by https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,macos
+# Edit at https://www.toptal.com/developers/gitignore?templates=python,visualstudiocode,macos
+
+### macOS ###
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### macOS Patch ###
+# iCloud generated files
+*.icloud
+
+### Python ###
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+#   For a library or package, you might want to ignore these files since the code is
+#   intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# poetry
+#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+#   This is especially recommended for binary packages to ensure reproducibility, and is more
+#   commonly ignored for libraries.
+#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+#   in version control.
+#   https://pdm.fming.dev/#use-with-ide
+.pdm.toml
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+#  and can be added to the global gitignore or merged into this file.  For a more nuclear
+#  option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+
+### Python Patch ###
+# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
+poetry.toml
+
+# ruff
+.ruff_cache/
+
+# LSP config files
+pyrightconfig.json
+
+### VisualStudioCode ###
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+!.vscode/*.code-snippets
+
+# Local History for Visual Studio Code
+.history/
+
+# Built Visual Studio Code Extensions
+*.vsix
+
+### VisualStudioCode Patch ###
+# Ignore all local history of files
+.history
+.ionide
+
+# End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,macos

+ 15 - 0
Dockerfile

@@ -0,0 +1,15 @@
+FROM python:3.10.11
+
+ENV PYTHONUNBUFFERED 1
+
+EXPOSE 8000
+WORKDIR /app
+
+COPY requirements.txt ./
+RUN pip install --upgrade pip && \
+    pip install -r requirements.txt
+
+COPY . ./
+
+ENV PYTHONPATH hfapi
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

+ 58 - 0
Makefile

@@ -0,0 +1,58 @@
+SHELL := /bin/bash
+
+# Variables definitions
+# -----------------------------------------------------------------------------
+
+DEFAULT_MODEL_PATH := ./mlmodels/
+
+ifeq ($(API_KEY),)
+API_KEY := $(python -c "import uuid;print(str(uuid.uuid4()))")
+endif
+
+
+# Target section and Global definitions
+# -----------------------------------------------------------------------------
+.PHONY: all clean test install run deploy down
+
+all: clean test install run deploy down
+
+test:
+	python -m pip install --upgrade pip && pip install setuptools tox
+	tox
+
+install: generate_dot_env
+	pip install --upgrade pip && pip install -r requirements.txt
+
+run: 
+	- python -c "import subprocess;print(subprocess.run(['hostname','-I'],capture_output=True,text=True).stdout.strip())"
+	PYTHONPATH=hfapi/ uvicorn hfapi.main:app --reload --host 0.0.0.0
+
+build: generate_dot_env
+	docker compose build
+
+deploy: generate_dot_env
+	docker compose build
+	docker compose up -d
+
+down:
+	docker compose down
+
+generate_dot_env:
+	@if [[ ! -e .env ]]; then \
+		cp .env.example .env; \
+	fi
+
+clean:
+	-find . -name '*.pyc' -exec rm -rf {} \;
+	-find . -name '__pycache__' -exec rm -rf {} \;
+	-find . -name 'Thumbs.db' -exec rm -rf {} \;
+	-find . -name '*~' -exec rm -rf {} \;
+	-rm -rf .cache
+	-rm -rf build
+	-rm -rf dist
+	-rm -rf *.egg-info
+	-rm -rf htmlcov*
+	-rm -rf .tox/
+	-rm -rf docs/_build
+	-rm -r .coverage
+	-rm -rf .pytest_cache

+ 28 - 0
README.md

@@ -0,0 +1,28 @@
+# ICI HuggingFace API via FastAPI
+> 🤗 Huggingface + ⚡ FastAPI = ❤️ Awesomeness. Structure Deep Learning models serving REST API with FastAPI
+
+Inspired by and bootstraped from  https://github.com/Proteusiq/huggingfastapi/
+
+![hfapi](assets/hf.png)
+
+Serve Hugging face models with FastAPI, the Python's fastest REST API framework. 
+
+The minimalistic project structure for development and production. 
+
+Installation and setup instructions to 
+run the development mode model and serve a local RESTful API endpoint.
+
+## Project structure
+
+Files related to application are in the `huggingfastapi` or `tests` directories.
+Application parts are:
+
+    hfapi
+    ├── api              - Main API.
+    │   └── routes       - Web routes.
+    ├── core             - Application configuration, startup events, logging.
+    ├── models           - Pydantic models for api.
+    ├── services         - NLP logics.
+    └── main.py          - FastAPI application creation and configuration.
+    │
+    tests                - Codes without tests is an illusion 

二进制
assets/hf.png


+ 11 - 0
docker-compose.yml

@@ -0,0 +1,11 @@
+version: "3"
+
+services:
+  app:
+    build: .
+    image: hfapi:latest
+    ports:
+      - 8000:8000
+    env_file:
+      - .env
+    container_name: hfapi

+ 0 - 0
hfapi/__init__.py


+ 0 - 0
hfapi/api/__init__.py


+ 0 - 0
hfapi/api/routes/__init__.py


+ 11 - 0
hfapi/api/routes/heartbeat.py

@@ -0,0 +1,11 @@
+from fastapi import APIRouter
+
+from hfapi.models.heartbeat import HearbeatResult
+
+router = APIRouter()
+
+
+@router.get("/heartbeat", response_model=HearbeatResult, name="heartbeat")
+def get_hearbeat() -> HearbeatResult:
+    heartbeat = HearbeatResult(is_alive=True)
+    return heartbeat

+ 26 - 0
hfapi/api/routes/prediction.py

@@ -0,0 +1,26 @@
+from fastapi import APIRouter, Depends
+from starlette.requests import Request
+
+from hfapi.core import security
+from hfapi.models.payload import QAPredictionPayload
+from hfapi.models.prediction import QAPredictionResult
+from hfapi.services.nlp import QAModel
+
+router = APIRouter()
+
+
+@router.post("/question", response_model=QAPredictionResult, name="question")
+def post_question(
+    request: Request,
+    authenticated: bool = Depends(security.validate_request),
+    block_data: QAPredictionPayload = None,
+) -> QAPredictionResult:
+    """
+    #### Retrieves an answer from context given a question
+
+    """
+
+    model: QAModel = request.app.state.model
+    prediction: QAPredictionResult = model.predict(block_data)
+
+    return prediction

+ 7 - 0
hfapi/api/routes/router.py

@@ -0,0 +1,7 @@
+from fastapi import APIRouter
+
+from hfapi.api.routes import heartbeat, prediction
+
+api_router = APIRouter()
+api_router.include_router(heartbeat.router, tags=["health"], prefix="/health")
+api_router.include_router(prediction.router, tags=["prediction"], prefix="/v1")

+ 0 - 0
hfapi/core/__init__.py


+ 14 - 0
hfapi/core/config.py

@@ -0,0 +1,14 @@
+from starlette.config import Config
+from starlette.datastructures import Secret
+
+APP_VERSION = "0.1.0"
+APP_NAME = "HFAPI"
+API_PREFIX = "/api"
+
+config = Config(".env")
+
+API_KEY: Secret = config("API_KEY", cast=Secret)
+IS_DEBUG: bool = config("IS_DEBUG", cast=bool, default=False)
+
+DEFAULT_MODEL_PATH: str = config("DEFAULT_MODEL_PATH")
+QUESTION_ANSWER_MODEL: str = config("QUESTION_ANSWER_MODEL")

+ 33 - 0
hfapi/core/event_handlers.py

@@ -0,0 +1,33 @@
+from typing import Callable
+
+from fastapi import FastAPI
+from loguru import logger
+
+from hfapi.core.config import DEFAULT_MODEL_PATH
+from hfapi.services.nlp import QAModel
+
+
+def _startup_model(app: FastAPI) -> None:
+    model_path = DEFAULT_MODEL_PATH
+    model_instance = QAModel(model_path)
+    app.state.model = model_instance
+
+
+def _shutdown_model(app: FastAPI) -> None:
+    app.state.model = None
+
+
+def start_app_handler(app: FastAPI) -> Callable:
+    def startup() -> None:
+        logger.info("Running app start handler.")
+        _startup_model(app)
+
+    return startup
+
+
+def stop_app_handler(app: FastAPI) -> Callable:
+    def shutdown() -> None:
+        logger.info("Running app shutdown handler.")
+        _shutdown_model(app)
+
+    return shutdown

+ 6 - 0
hfapi/core/messages.py

@@ -0,0 +1,6 @@
+NO_API_KEY = "No API key provided."
+AUTH_REQ = "Authentication required."
+HTTP_500_DETAIL = "Internal server error."
+
+# templates
+NO_VALID_PAYLOAD = "{} is not a valid payload."

+ 23 - 0
hfapi/core/security.py

@@ -0,0 +1,23 @@
+import secrets
+from typing import Optional
+
+from fastapi import HTTPException, Security
+from fastapi.security.api_key import APIKeyHeader
+from starlette.status import HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED
+
+from hfapi.core import config
+from hfapi.core.messages import AUTH_REQ, NO_API_KEY
+
+api_key = APIKeyHeader(name="token", auto_error=False)
+
+
+def validate_request(header: Optional[str] = Security(api_key)) -> bool:
+    if header is None:
+        raise HTTPException(
+            status_code=HTTP_400_BAD_REQUEST, detail=NO_API_KEY, headers={}
+        )
+    if not secrets.compare_digest(header, str(config.API_KEY)):
+        raise HTTPException(
+            status_code=HTTP_401_UNAUTHORIZED, detail=AUTH_REQ, headers={}
+        )
+    return True

+ 17 - 0
hfapi/main.py

@@ -0,0 +1,17 @@
+from fastapi import FastAPI
+from hfapi.api.routes.router import api_router
+from hfapi.core.config import API_PREFIX, APP_NAME, APP_VERSION, IS_DEBUG
+from hfapi.core.event_handlers import start_app_handler, stop_app_handler
+
+
+def get_app() -> FastAPI:
+    fast_app = FastAPI(title=APP_NAME, version=APP_VERSION, debug=IS_DEBUG)
+    fast_app.include_router(api_router, prefix=API_PREFIX)
+
+    fast_app.add_event_handler("startup", start_app_handler(fast_app))
+    fast_app.add_event_handler("shutdown", stop_app_handler(fast_app))
+
+    return fast_app
+
+
+app = get_app()

+ 0 - 0
hfapi/models/__init__.py


+ 5 - 0
hfapi/models/heartbeat.py

@@ -0,0 +1,5 @@
+from pydantic import BaseModel
+
+
+class HearbeatResult(BaseModel):
+    is_alive: bool

+ 7 - 0
hfapi/models/payload.py

@@ -0,0 +1,7 @@
+from pydantic import BaseModel
+
+
+class QAPredictionPayload(BaseModel):
+
+    context: str = "42 is the answer to life, the universe and everything."
+    question: str = "What is the answer to life?"

+ 11 - 0
hfapi/models/prediction.py

@@ -0,0 +1,11 @@
+from pydantic import BaseModel
+from hfapi.core.config import QUESTION_ANSWER_MODEL
+
+
+class QAPredictionResult(BaseModel):
+
+    score: float
+    start: int
+    end: int
+    answer: str
+    model: str = QUESTION_ANSWER_MODEL

+ 0 - 0
hfapi/services/__init__.py


+ 69 - 0
hfapi/services/nlp.py

@@ -0,0 +1,69 @@
+from typing import Dict, List
+from loguru import logger
+
+from transformers import AutoTokenizer
+from transformers import AutoModelForQuestionAnswering
+from transformers import pipeline
+
+from hfapi.models.payload import QAPredictionPayload
+from hfapi.models.prediction import QAPredictionResult
+from hfapi.services.utils import ModelLoader
+from hfapi.core.messages import NO_VALID_PAYLOAD
+from hfapi.core.config import (
+    DEFAULT_MODEL_PATH,
+    QUESTION_ANSWER_MODEL,
+)
+
+
+class QAModel(object):
+    def __init__(self, path=DEFAULT_MODEL_PATH):
+        self.path = path
+        self._load_local_model()
+
+    def _load_local_model(self):
+
+        tokenizer, model = ModelLoader(
+            model_name=QUESTION_ANSWER_MODEL,
+            model_directory=DEFAULT_MODEL_PATH,
+            tokenizer_loader=AutoTokenizer,
+            model_loader=AutoModelForQuestionAnswering,
+        ).retrieve()
+
+        self.nlp = pipeline("question-answering", model=model, tokenizer=tokenizer)
+
+    def _pre_process(self, payload: QAPredictionPayload) -> List:
+        logger.debug("Pre-processing payload.")
+        result = [payload.context, payload.question]
+        return result
+
+    def _post_process(self, prediction: Dict) -> QAPredictionResult:
+        logger.debug("Post-processing prediction.")
+
+        qa = QAPredictionResult(**prediction)
+
+        return qa
+
+    def _predict(self, features: List) -> tuple:
+        logger.debug("Predicting.")
+
+        context, question = features
+
+        QA_input = {
+            "question": question,
+            "context": context,
+        }
+
+        prediction_result = self.nlp(QA_input)
+
+        return prediction_result
+
+    def predict(self, payload: QAPredictionPayload):
+        if payload is None:
+            raise ValueError(NO_VALID_PAYLOAD.format(payload))
+
+        pre_processed_payload = self._pre_process(payload)
+        prediction = self._predict(pre_processed_payload)
+        logger.info(prediction)
+        post_processed_result = self._post_process(prediction)
+
+        return post_processed_result

+ 75 - 0
hfapi/services/utils.py

@@ -0,0 +1,75 @@
+import typing as t
+from loguru import logger
+from pathlib import Path
+import torch
+from transformers import PreTrainedModel
+from transformers import PreTrainedTokenizer
+
+
+class ModelLoader:
+    """ModelLoader
+    Downloading and Loading Hugging FaceModels
+       Download occurs only when model is not located in the local model directory
+       If model exists in local directory, load.
+
+    """
+
+    def __init__(
+        self,
+        model_name: str,
+        model_directory: str,
+        tokenizer_loader: PreTrainedTokenizer,
+        model_loader: PreTrainedModel,
+    ):
+
+        self.model_name = Path(model_name)
+        self.model_directory = Path(model_directory)
+        self.model_loader = model_loader
+        self.tokenizer_loader = tokenizer_loader
+
+        self.save_path = self.model_directory / self.model_name
+
+        if not self.save_path.exists():
+            logger.debug(f"[+] {self.save_path} does not exit!")
+            self.save_path.mkdir(parents=True, exist_ok=True)
+            self.__download_model()
+
+        self.tokenizer, self.model = self.__load_model()
+
+    def __repr__(self):
+        return f"{self.__class__.__name__}(model={self.save_path})"
+
+    # Download model from HuggingFace
+    def __download_model(self) -> None:
+
+        logger.debug(f"[+] Downloading {self.model_name}")
+        tokenizer = self.tokenizer_loader.from_pretrained(f"{self.model_name}")
+        model = self.model_loader.from_pretrained(f"{self.model_name}")
+
+        logger.debug(f"[+] Saving {self.model_name} to {self.save_path}")
+        tokenizer.save_pretrained(f"{self.save_path}")
+        model.save_pretrained(f"{self.save_path}")
+
+        logger.debug("[+] Process completed")
+
+    # Load model
+    def __load_model(self) -> t.Tuple:
+
+        logger.debug(f"[+] Loading model from {self.save_path}")
+        tokenizer = self.tokenizer_loader.from_pretrained(f"{self.save_path}")
+        # Check if GPU is available
+        device = "cuda" if torch.cuda.is_available() else "cpu"
+        logger.info(f"[+] Model loaded in {device} complete")
+        model = self.model_loader.from_pretrained(f"{self.save_path}").to(device)
+
+        logger.debug("[+] Loading completed")
+        return tokenizer, model
+
+    def retrieve(self) -> t.Tuple:
+
+        """Retriver
+
+        Returns:
+            Tuple: tokenizer, model
+        """
+        return self.tokenizer, self.model

+ 2 - 0
mlmodels/.gitignore

@@ -0,0 +1,2 @@
+*
+!.gitignore

+ 32 - 0
requirements.txt

@@ -0,0 +1,32 @@
+anyio==3.6.2
+certifi==2022.12.7
+charset-normalizer==3.1.0
+click==8.1.3
+fastapi==0.95.1
+filelock==3.12.0
+fsspec==2023.4.0
+h11==0.14.0
+huggingface-hub==0.14.0
+idna==3.4
+Jinja2==3.1.2
+loguru==0.7.0
+MarkupSafe==2.1.2
+mpmath==1.3.0
+networkx==3.1
+numpy==1.24.3
+packaging==23.1
+pydantic==1.10.7
+PyYAML==6.0
+regex==2023.3.23
+requests==2.28.2
+sentencepiece==0.1.98
+sniffio==1.3.0
+starlette==0.26.1
+sympy==1.11.1
+tokenizers==0.13.3
+torch==2.0.0
+tqdm==4.65.0
+transformers==4.28.1
+typing_extensions==4.5.0
+urllib3==1.26.15
+uvicorn==0.21.1

+ 9 - 0
setup.cfg

@@ -0,0 +1,9 @@
+[tool:pytest]
+testpaths = tests
+
+[coverage:run]
+source = app
+branch = True
+
+[coverage:report]
+precision = 2

+ 12 - 0
setup.py

@@ -0,0 +1,12 @@
+#!/usr/bin/env python
+
+from distutils.core import setup
+
+setup(
+    name="HF_FASTAPI",
+    version="0.1.0",
+    description="Hugging Face FastAPI Wrapper and utilities",
+    author="Radu Boncea",
+    author_email="radu.boncea@ici.ro",
+    packages=["hfapi"],
+)

+ 66 - 0
tox.ini

@@ -0,0 +1,66 @@
+[tox]
+envlist = flake8,py310,bandit
+
+[testenv]
+deps = 
+    coverage
+    pytest
+    pytest-cov
+    pycodestyle>=2.0
+commands =
+    coverage erase
+    pip install -r {toxinidir}/requirements.txt
+    pytest --cov=tests --cov=hfapi --cov-report=term-missing --cov-config=setup.cfg 
+    coverage report
+    coverage html -d htmlcov-{envname}   
+
+[testenv:autopep8]
+basepython = python3
+skip_install = true
+deps =
+    autopep8>=1.5
+commands =
+    autopep8 --in-place --aggressive -r hfapi/
+
+
+[testenv:flake8]
+basepython = python3
+skip_install = true
+deps =
+    flake8
+    flake8-bugbear
+    flake8-colors
+    flake8-typing-imports>=1.1
+    pep8-naming
+commands =
+    flake8 hfapi/ tests/ setup.py
+
+[testenv:bandit]
+basepython = python3
+deps =
+    bandit
+commands =
+    bandit -r hfapi/
+
+[flake8]
+exclude =
+    .tox,
+    .git,
+    __pycache__,
+    docs/source/conf.py,
+    build,
+    dist,
+    tests/fixtures/*,
+    *.pyc,
+    *.egg-info,
+    .cache,
+    .eggs
+max-complexity = 10
+import-order-style = google
+application-import-names = flake8
+max-line-length = 120
+ignore = 
+    B008,
+    N803,
+    N806,
+    E126