Spaces:
Sleeping
Sleeping
marintosti12 commited on
Commit ·
d0fda5a
1
Parent(s): a7e3b78
setup api
Browse files- .github/workflows/ci.yaml +80 -0
- .github/workflows/deploy.yaml +84 -0
- Dockerfile +37 -0
- alembic.ini +147 -0
- alembic/README +1 -0
- alembic/env.py +56 -0
- alembic/versions/99e339b56253_initial_schema.py +35 -0
- docker-compose.yml +19 -0
- poetry.lock +280 -1
- pyproject.toml +7 -0
- src/config/db.py +22 -0
- src/controllers/home_controller.py +88 -0
- src/controllers/predict_controller.py +163 -0
- src/features.py +38 -0
- src/main.py +18 -0
- src/model_loader.py +35 -0
- src/schemas/ModelFeatures.py +36 -0
- src/schemas/PredictItemResult.py +7 -0
- src/schemas/PredictRequest.py +8 -0
- src/schemas/PredictResponse.py +9 -0
- src/schemas/__init__.py +0 -0
- src/seeds/ml_models_seed.py +38 -0
- tests/functional/test_home.py +92 -0
- tests/functional/test_predict.py +364 -0
- tests/unit/test_features.py +17 -0
.github/workflows/ci.yaml
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [ main, dev, test, "feature/*", "fix/*" ]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [ main, dev, test ]
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
test:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
timeout-minutes: 10
|
| 13 |
+
|
| 14 |
+
services:
|
| 15 |
+
postgres:
|
| 16 |
+
image: postgres:16
|
| 17 |
+
env:
|
| 18 |
+
POSTGRES_USER: ci
|
| 19 |
+
POSTGRES_PASSWORD: ci
|
| 20 |
+
POSTGRES_DB: futurisys_ci
|
| 21 |
+
|
| 22 |
+
ports:
|
| 23 |
+
- 5432:5432
|
| 24 |
+
options: >-
|
| 25 |
+
--health-cmd="pg_isready -U ci -d futurisys_ci"
|
| 26 |
+
--health-interval=5s
|
| 27 |
+
--health-timeout=5s
|
| 28 |
+
--health-retries=20
|
| 29 |
+
|
| 30 |
+
env:
|
| 31 |
+
DATABASE_URL: postgresql+asyncpg://ci:ci@localhost:5432/futurisys_ci
|
| 32 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 33 |
+
APP_ENV: test
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
steps:
|
| 37 |
+
- name: Checkout
|
| 38 |
+
uses: actions/checkout@v4
|
| 39 |
+
|
| 40 |
+
- name: Set up Python
|
| 41 |
+
uses: actions/setup-python@v5
|
| 42 |
+
with:
|
| 43 |
+
python-version: "3.12"
|
| 44 |
+
|
| 45 |
+
- name: Install Poetry
|
| 46 |
+
uses: abatilo/actions-poetry@v3
|
| 47 |
+
with:
|
| 48 |
+
poetry-version: "1.8.3"
|
| 49 |
+
|
| 50 |
+
- name: Cache Poetry virtualenv
|
| 51 |
+
uses: actions/cache@v4
|
| 52 |
+
with:
|
| 53 |
+
path: |
|
| 54 |
+
~/.cache/pypoetry
|
| 55 |
+
.venv
|
| 56 |
+
key: ${{ runner.os }}-poetry-3.12-${{ hashFiles('**/poetry.lock') }}
|
| 57 |
+
|
| 58 |
+
- name: Install deps
|
| 59 |
+
run: poetry install --no-interaction --no-ansi
|
| 60 |
+
|
| 61 |
+
- name: Wait for Postgres
|
| 62 |
+
run: |
|
| 63 |
+
sudo apt-get update && sudo apt-get install -y postgresql-client
|
| 64 |
+
for i in {1..30}; do
|
| 65 |
+
pg_isready -h localhost -p 5432 -U ci -d futurisys_ci && break
|
| 66 |
+
sleep 2
|
| 67 |
+
done
|
| 68 |
+
|
| 69 |
+
- name: Run Alembic migrations
|
| 70 |
+
run: PYTHONPATH=./src poetry run alembic upgrade head
|
| 71 |
+
|
| 72 |
+
- name: Lint (ruff)
|
| 73 |
+
run: |
|
| 74 |
+
poetry run python -m pip install ruff
|
| 75 |
+
poetry run ruff check .
|
| 76 |
+
|
| 77 |
+
- name: Tests (pytest + coverage)
|
| 78 |
+
run: |
|
| 79 |
+
poetry run python -m pip install pytest pytest-cov
|
| 80 |
+
poetry run pytest
|
.github/workflows/deploy.yaml
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Deploy to Hugging Face
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
workflow_run:
|
| 5 |
+
workflows: ["CI"]
|
| 6 |
+
branches: [ main ]
|
| 7 |
+
types: [completed]
|
| 8 |
+
workflow_dispatch: {}
|
| 9 |
+
|
| 10 |
+
concurrency:
|
| 11 |
+
group: deploy-${{ github.event.workflow_run.head_branch || 'main' }}
|
| 12 |
+
cancel-in-progress: true
|
| 13 |
+
|
| 14 |
+
jobs:
|
| 15 |
+
deploy:
|
| 16 |
+
runs-on: ubuntu-latest
|
| 17 |
+
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }}
|
| 18 |
+
|
| 19 |
+
env:
|
| 20 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 21 |
+
HF_SPACE_URL: ${{ secrets.HF_SPACE_URL }}
|
| 22 |
+
HF_GIT_EMAIL: ${{ secrets.HF_GIT_EMAIL || 'actions@github.com' }}
|
| 23 |
+
HF_GIT_NAME: ${{ secrets.HF_GIT_NAME || 'github-actions' }}
|
| 24 |
+
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
steps:
|
| 28 |
+
- name: Checkout
|
| 29 |
+
uses: actions/checkout@v4
|
| 30 |
+
with:
|
| 31 |
+
fetch-depth: 0
|
| 32 |
+
lfs: true
|
| 33 |
+
|
| 34 |
+
- name: Guard secrets
|
| 35 |
+
if: ${{ !env.HF_TOKEN || !env.HF_SPACE_URL }}
|
| 36 |
+
run: |
|
| 37 |
+
echo "HF secrets manquants, on skip le déploiement."
|
| 38 |
+
exit 0
|
| 39 |
+
|
| 40 |
+
- name: Configure Git identity (local)
|
| 41 |
+
shell: bash
|
| 42 |
+
run: |
|
| 43 |
+
git config user.email "${HF_GIT_EMAIL:-actions@github.com}"
|
| 44 |
+
git config user.name "${HF_GIT_NAME:-github-actions}"
|
| 45 |
+
|
| 46 |
+
- name: Convert binaries to Git LFS
|
| 47 |
+
shell: bash
|
| 48 |
+
run: |
|
| 49 |
+
set -e
|
| 50 |
+
git lfs install
|
| 51 |
+
git lfs track "*.joblib" "*.pkl" "*.pt" "*.onnx"
|
| 52 |
+
git add .gitattributes
|
| 53 |
+
git commit -m "ci: track ML artifacts with LFS for Space push" || true
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
git lfs migrate import --everything --include="*.joblib,notebook/df.joblib,*.pkl,*.pt,*.onnx"
|
| 57 |
+
|
| 58 |
+
git lfs status || true
|
| 59 |
+
|
| 60 |
+
- name: Setup Python
|
| 61 |
+
uses: actions/setup-python@v5
|
| 62 |
+
with:
|
| 63 |
+
python-version: "3.12"
|
| 64 |
+
|
| 65 |
+
- name: Install Poetry
|
| 66 |
+
uses: abatilo/actions-poetry@v3
|
| 67 |
+
with:
|
| 68 |
+
poetry-version: "1.8.3"
|
| 69 |
+
|
| 70 |
+
- name: Install deps (with dev)
|
| 71 |
+
run: poetry install --no-interaction --no-root
|
| 72 |
+
|
| 73 |
+
- name: Run Alembic migrations
|
| 74 |
+
env:
|
| 75 |
+
DATABASE_URL: ${{ env.DATABASE_URL }}
|
| 76 |
+
run: |
|
| 77 |
+
echo "Migrating DB: $DATABASE_URL"
|
| 78 |
+
PYTHONPATH=./src poetry run alembic upgrade head
|
| 79 |
+
|
| 80 |
+
- name: Push to Space
|
| 81 |
+
run: |
|
| 82 |
+
SPACE_URL_AUTH=$(echo "$HF_SPACE_URL" | sed "s#https://#https://user:${HF_TOKEN}@#")
|
| 83 |
+
git remote add space "$SPACE_URL_AUTH" || git remote set-url space "$SPACE_URL_AUTH"
|
| 84 |
+
git push space HEAD:main --force
|
Dockerfile
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
| 4 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 5 |
+
PYTHONUNBUFFERED=1 \
|
| 6 |
+
PORT=7860
|
| 7 |
+
|
| 8 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 9 |
+
build-essential libgomp1 \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# Créer l'utilisateur exigé par Spaces et passer en non-root
|
| 13 |
+
RUN useradd -m -u 1000 user
|
| 14 |
+
USER user
|
| 15 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
| 16 |
+
WORKDIR /app
|
| 17 |
+
|
| 18 |
+
# Installer Poetry
|
| 19 |
+
RUN pip install --no-cache-dir "poetry==1.8.3" && poetry --version
|
| 20 |
+
|
| 21 |
+
COPY --chown=user pyproject.toml poetry.lock* /app/
|
| 22 |
+
|
| 23 |
+
# Création env poetry
|
| 24 |
+
RUN poetry config virtualenvs.create true \
|
| 25 |
+
&& poetry config virtualenvs.in-project true \
|
| 26 |
+
&& poetry install --no-interaction --no-ansi --only main
|
| 27 |
+
|
| 28 |
+
# Ajouter le venv au PATH pour trouver uvicorn/python
|
| 29 |
+
ENV PATH="/app/.venv/bin:$PATH"
|
| 30 |
+
|
| 31 |
+
# Copier le code
|
| 32 |
+
COPY --chown=user src /app/src
|
| 33 |
+
|
| 34 |
+
EXPOSE 7860
|
| 35 |
+
|
| 36 |
+
# Lancer FastAPI
|
| 37 |
+
CMD ["uvicorn", "main:app", "--app-dir", "src", "--host", "0.0.0.0", "--port", "7860"]
|
alembic.ini
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# A generic, single database configuration.
|
| 2 |
+
|
| 3 |
+
[alembic]
|
| 4 |
+
# path to migration scripts.
|
| 5 |
+
# this is typically a path given in POSIX (e.g. forward slashes)
|
| 6 |
+
# format, relative to the token %(here)s which refers to the location of this
|
| 7 |
+
# ini file
|
| 8 |
+
script_location = %(here)s/alembic
|
| 9 |
+
|
| 10 |
+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
| 11 |
+
# Uncomment the line below if you want the files to be prepended with date and time
|
| 12 |
+
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
| 13 |
+
# for all available tokens
|
| 14 |
+
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
| 15 |
+
|
| 16 |
+
# sys.path path, will be prepended to sys.path if present.
|
| 17 |
+
# defaults to the current working directory. for multiple paths, the path separator
|
| 18 |
+
# is defined by "path_separator" below.
|
| 19 |
+
prepend_sys_path = .
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# timezone to use when rendering the date within the migration file
|
| 23 |
+
# as well as the filename.
|
| 24 |
+
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
| 25 |
+
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
| 26 |
+
# string value is passed to ZoneInfo()
|
| 27 |
+
# leave blank for localtime
|
| 28 |
+
# timezone =
|
| 29 |
+
|
| 30 |
+
# max length of characters to apply to the "slug" field
|
| 31 |
+
# truncate_slug_length = 40
|
| 32 |
+
|
| 33 |
+
# set to 'true' to run the environment during
|
| 34 |
+
# the 'revision' command, regardless of autogenerate
|
| 35 |
+
# revision_environment = false
|
| 36 |
+
|
| 37 |
+
# set to 'true' to allow .pyc and .pyo files without
|
| 38 |
+
# a source .py file to be detected as revisions in the
|
| 39 |
+
# versions/ directory
|
| 40 |
+
# sourceless = false
|
| 41 |
+
|
| 42 |
+
# version location specification; This defaults
|
| 43 |
+
# to <script_location>/versions. When using multiple version
|
| 44 |
+
# directories, initial revisions must be specified with --version-path.
|
| 45 |
+
# The path separator used here should be the separator specified by "path_separator"
|
| 46 |
+
# below.
|
| 47 |
+
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
| 48 |
+
|
| 49 |
+
# path_separator; This indicates what character is used to split lists of file
|
| 50 |
+
# paths, including version_locations and prepend_sys_path within configparser
|
| 51 |
+
# files such as alembic.ini.
|
| 52 |
+
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
| 53 |
+
# to provide os-dependent path splitting.
|
| 54 |
+
#
|
| 55 |
+
# Note that in order to support legacy alembic.ini files, this default does NOT
|
| 56 |
+
# take place if path_separator is not present in alembic.ini. If this
|
| 57 |
+
# option is omitted entirely, fallback logic is as follows:
|
| 58 |
+
#
|
| 59 |
+
# 1. Parsing of the version_locations option falls back to using the legacy
|
| 60 |
+
# "version_path_separator" key, which if absent then falls back to the legacy
|
| 61 |
+
# behavior of splitting on spaces and/or commas.
|
| 62 |
+
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
| 63 |
+
# behavior of splitting on spaces, commas, or colons.
|
| 64 |
+
#
|
| 65 |
+
# Valid values for path_separator are:
|
| 66 |
+
#
|
| 67 |
+
# path_separator = :
|
| 68 |
+
# path_separator = ;
|
| 69 |
+
# path_separator = space
|
| 70 |
+
# path_separator = newline
|
| 71 |
+
#
|
| 72 |
+
# Use os.pathsep. Default configuration used for new projects.
|
| 73 |
+
path_separator = os
|
| 74 |
+
|
| 75 |
+
# set to 'true' to search source files recursively
|
| 76 |
+
# in each "version_locations" directory
|
| 77 |
+
# new in Alembic version 1.10
|
| 78 |
+
# recursive_version_locations = false
|
| 79 |
+
|
| 80 |
+
# the output encoding used when revision files
|
| 81 |
+
# are written from script.py.mako
|
| 82 |
+
# output_encoding = utf-8
|
| 83 |
+
|
| 84 |
+
# database URL. This is consumed by the user-maintained env.py script only.
|
| 85 |
+
# other means of configuring database URLs may be customized within the env.py
|
| 86 |
+
# file.
|
| 87 |
+
sqlalchemy.url = driver://user:pass@localhost/dbname
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
[post_write_hooks]
|
| 91 |
+
# post_write_hooks defines scripts or Python functions that are run
|
| 92 |
+
# on newly generated revision scripts. See the documentation for further
|
| 93 |
+
# detail and examples
|
| 94 |
+
|
| 95 |
+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
| 96 |
+
# hooks = black
|
| 97 |
+
# black.type = console_scripts
|
| 98 |
+
# black.entrypoint = black
|
| 99 |
+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
| 100 |
+
|
| 101 |
+
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
| 102 |
+
# hooks = ruff
|
| 103 |
+
# ruff.type = module
|
| 104 |
+
# ruff.module = ruff
|
| 105 |
+
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
| 106 |
+
|
| 107 |
+
# Alternatively, use the exec runner to execute a binary found on your PATH
|
| 108 |
+
# hooks = ruff
|
| 109 |
+
# ruff.type = exec
|
| 110 |
+
# ruff.executable = ruff
|
| 111 |
+
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
| 112 |
+
|
| 113 |
+
# Logging configuration. This is also consumed by the user-maintained
|
| 114 |
+
# env.py script only.
|
| 115 |
+
[loggers]
|
| 116 |
+
keys = root,sqlalchemy,alembic
|
| 117 |
+
|
| 118 |
+
[handlers]
|
| 119 |
+
keys = console
|
| 120 |
+
|
| 121 |
+
[formatters]
|
| 122 |
+
keys = generic
|
| 123 |
+
|
| 124 |
+
[logger_root]
|
| 125 |
+
level = WARNING
|
| 126 |
+
handlers = console
|
| 127 |
+
qualname =
|
| 128 |
+
|
| 129 |
+
[logger_sqlalchemy]
|
| 130 |
+
level = WARNING
|
| 131 |
+
handlers =
|
| 132 |
+
qualname = sqlalchemy.engine
|
| 133 |
+
|
| 134 |
+
[logger_alembic]
|
| 135 |
+
level = INFO
|
| 136 |
+
handlers =
|
| 137 |
+
qualname = alembic
|
| 138 |
+
|
| 139 |
+
[handler_console]
|
| 140 |
+
class = StreamHandler
|
| 141 |
+
args = (sys.stderr,)
|
| 142 |
+
level = NOTSET
|
| 143 |
+
formatter = generic
|
| 144 |
+
|
| 145 |
+
[formatter_generic]
|
| 146 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
| 147 |
+
datefmt = %H:%M:%S
|
alembic/README
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Generic single-database configuration.
|
alembic/env.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from logging.config import fileConfig
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
from alembic import context
|
| 6 |
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
|
| 7 |
+
from sqlalchemy import pool
|
| 8 |
+
|
| 9 |
+
from src.config.db import Base
|
| 10 |
+
from dotenv import load_dotenv, find_dotenv
|
| 11 |
+
load_dotenv(find_dotenv())
|
| 12 |
+
|
| 13 |
+
config = context.config
|
| 14 |
+
if config.config_file_name is not None:
|
| 15 |
+
fileConfig(config.config_file_name)
|
| 16 |
+
|
| 17 |
+
target_metadata = Base.metadata
|
| 18 |
+
|
| 19 |
+
def get_url() -> str:
|
| 20 |
+
url = os.getenv("DATABASE_URL")
|
| 21 |
+
return url
|
| 22 |
+
|
| 23 |
+
def run_migrations_offline() -> None:
|
| 24 |
+
context.configure(
|
| 25 |
+
url=get_url(),
|
| 26 |
+
target_metadata=target_metadata,
|
| 27 |
+
literal_binds=True,
|
| 28 |
+
compare_type=True,
|
| 29 |
+
dialect_opts={"paramstyle": "named"},
|
| 30 |
+
)
|
| 31 |
+
with context.begin_transaction():
|
| 32 |
+
context.run_migrations()
|
| 33 |
+
|
| 34 |
+
def do_run_migrations(connection) -> None:
|
| 35 |
+
context.configure(
|
| 36 |
+
connection=connection,
|
| 37 |
+
target_metadata=target_metadata,
|
| 38 |
+
compare_type=True,
|
| 39 |
+
)
|
| 40 |
+
with context.begin_transaction():
|
| 41 |
+
context.run_migrations()
|
| 42 |
+
|
| 43 |
+
async def run_migrations_online() -> None:
|
| 44 |
+
connectable: AsyncEngine = create_async_engine(
|
| 45 |
+
get_url(),
|
| 46 |
+
poolclass=pool.NullPool,
|
| 47 |
+
)
|
| 48 |
+
async with connectable.connect() as connection:
|
| 49 |
+
await connection.run_sync(do_run_migrations)
|
| 50 |
+
await connectable.dispose()
|
| 51 |
+
|
| 52 |
+
if context.is_offline_mode():
|
| 53 |
+
run_migrations_offline()
|
| 54 |
+
else:
|
| 55 |
+
import asyncio
|
| 56 |
+
asyncio.run(run_migrations_online())
|
alembic/versions/99e339b56253_initial_schema.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""initial schema
|
| 2 |
+
|
| 3 |
+
Revision ID: 99e339b56253
|
| 4 |
+
Revises:
|
| 5 |
+
Create Date: 2025-09-15 12:01:54.205742
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
from sqlalchemy.dialects import postgresql
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# revision identifiers, used by Alembic.
|
| 16 |
+
revision: str = '99e339b56253'
|
| 17 |
+
down_revision: Union[str, Sequence[str], None] = None
|
| 18 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 19 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def upgrade() -> None:
|
| 23 |
+
op.create_table(
|
| 24 |
+
"ml_models",
|
| 25 |
+
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
|
| 26 |
+
sa.Column("name", sa.String(length=100), nullable=False, unique=True),
|
| 27 |
+
sa.Column("description", sa.Text(), nullable=True),
|
| 28 |
+
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
| 29 |
+
sa.Column("is_active", sa.Boolean(), server_default=sa.text("TRUE"), nullable=False),
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def downgrade() -> None:
|
| 34 |
+
op.drop_table("ml_models")
|
| 35 |
+
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
db:
|
| 3 |
+
image: postgres:16
|
| 4 |
+
container_name: pret-db
|
| 5 |
+
environment:
|
| 6 |
+
POSTGRES_USER: pret
|
| 7 |
+
POSTGRES_PASSWORD: pret_pass
|
| 8 |
+
POSTGRES_DB: pret
|
| 9 |
+
ports:
|
| 10 |
+
- "5432:5432"
|
| 11 |
+
volumes:
|
| 12 |
+
- pgdata:/var/lib/postgresql/data
|
| 13 |
+
healthcheck:
|
| 14 |
+
test: ["CMD-SHELL", "pg_isready -U pret -d pret"]
|
| 15 |
+
interval: 5s
|
| 16 |
+
timeout: 5s
|
| 17 |
+
retries: 10
|
| 18 |
+
volumes:
|
| 19 |
+
pgdata:
|
poetry.lock
CHANGED
|
@@ -823,6 +823,19 @@ docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"]
|
|
| 823 |
ssh = ["paramiko (>=2.4.3)"]
|
| 824 |
websockets = ["websocket-client (>=1.3.0)"]
|
| 825 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 826 |
[[package]]
|
| 827 |
name = "executing"
|
| 828 |
version = "2.2.1"
|
|
@@ -873,6 +886,17 @@ files = [
|
|
| 873 |
[package.extras]
|
| 874 |
devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"]
|
| 875 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 876 |
[[package]]
|
| 877 |
name = "flask"
|
| 878 |
version = "3.1.2"
|
|
@@ -1002,6 +1026,45 @@ files = [
|
|
| 1002 |
{file = "fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f"},
|
| 1003 |
]
|
| 1004 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1005 |
[[package]]
|
| 1006 |
name = "gitdb"
|
| 1007 |
version = "4.0.12"
|
|
@@ -1205,6 +1268,40 @@ files = [
|
|
| 1205 |
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
|
| 1206 |
]
|
| 1207 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1208 |
[[package]]
|
| 1209 |
name = "httpcore"
|
| 1210 |
version = "1.0.9"
|
|
@@ -1265,6 +1362,41 @@ files = [
|
|
| 1265 |
backends = ["redis (>=3.0.0)"]
|
| 1266 |
redis = ["redis (>=3.0.0)"]
|
| 1267 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1268 |
[[package]]
|
| 1269 |
name = "idna"
|
| 1270 |
version = "3.11"
|
|
@@ -2943,6 +3075,101 @@ files = [
|
|
| 2943 |
dev = ["abi3audit", "black", "check-manifest", "colorama", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pyreadline", "pytest", "pytest-cov", "pytest-instafail", "pytest-subtests", "pytest-xdist", "pywin32", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel", "wmi"]
|
| 2944 |
test = ["pytest", "pytest-instafail", "pytest-subtests", "pytest-xdist", "pywin32", "setuptools", "wheel", "wmi"]
|
| 2945 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2946 |
[[package]]
|
| 2947 |
name = "ptyprocess"
|
| 2948 |
version = "0.7.0"
|
|
@@ -3213,6 +3440,29 @@ files = [
|
|
| 3213 |
[package.dependencies]
|
| 3214 |
typing-extensions = ">=4.14.1"
|
| 3215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3216 |
[[package]]
|
| 3217 |
name = "pygments"
|
| 3218 |
version = "2.19.2"
|
|
@@ -3994,6 +4244,17 @@ test = ["catboost", "causalml", "gpboost", "lightgbm", "ngboost", "numpy (<2.0)"
|
|
| 3994 |
test-core = ["mypy", "pytest", "pytest-cov", "pytest-mpl"]
|
| 3995 |
test-notebooks = ["datasets", "jupyter", "keras", "nbconvert", "nbformat", "nlp", "transformers"]
|
| 3996 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3997 |
[[package]]
|
| 3998 |
name = "six"
|
| 3999 |
version = "1.17.0"
|
|
@@ -4303,6 +4564,24 @@ files = [
|
|
| 4303 |
docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"]
|
| 4304 |
test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"]
|
| 4305 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4306 |
[[package]]
|
| 4307 |
name = "typing-extensions"
|
| 4308 |
version = "4.15.0"
|
|
@@ -4491,4 +4770,4 @@ type = ["pytest-mypy"]
|
|
| 4491 |
[metadata]
|
| 4492 |
lock-version = "2.0"
|
| 4493 |
python-versions = "^3.12"
|
| 4494 |
-
content-hash = "
|
|
|
|
| 823 |
ssh = ["paramiko (>=2.4.3)"]
|
| 824 |
websockets = ["websocket-client (>=1.3.0)"]
|
| 825 |
|
| 826 |
+
[[package]]
|
| 827 |
+
name = "dotenv"
|
| 828 |
+
version = "0.9.9"
|
| 829 |
+
description = "Deprecated package"
|
| 830 |
+
optional = false
|
| 831 |
+
python-versions = "*"
|
| 832 |
+
files = [
|
| 833 |
+
{file = "dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9"},
|
| 834 |
+
]
|
| 835 |
+
|
| 836 |
+
[package.dependencies]
|
| 837 |
+
python-dotenv = "*"
|
| 838 |
+
|
| 839 |
[[package]]
|
| 840 |
name = "executing"
|
| 841 |
version = "2.2.1"
|
|
|
|
| 886 |
[package.extras]
|
| 887 |
devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"]
|
| 888 |
|
| 889 |
+
[[package]]
|
| 890 |
+
name = "filelock"
|
| 891 |
+
version = "3.20.0"
|
| 892 |
+
description = "A platform independent file lock."
|
| 893 |
+
optional = false
|
| 894 |
+
python-versions = ">=3.10"
|
| 895 |
+
files = [
|
| 896 |
+
{file = "filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2"},
|
| 897 |
+
{file = "filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4"},
|
| 898 |
+
]
|
| 899 |
+
|
| 900 |
[[package]]
|
| 901 |
name = "flask"
|
| 902 |
version = "3.1.2"
|
|
|
|
| 1026 |
{file = "fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f"},
|
| 1027 |
]
|
| 1028 |
|
| 1029 |
+
[[package]]
|
| 1030 |
+
name = "fsspec"
|
| 1031 |
+
version = "2025.10.0"
|
| 1032 |
+
description = "File-system specification"
|
| 1033 |
+
optional = false
|
| 1034 |
+
python-versions = ">=3.9"
|
| 1035 |
+
files = [
|
| 1036 |
+
{file = "fsspec-2025.10.0-py3-none-any.whl", hash = "sha256:7c7712353ae7d875407f97715f0e1ffcc21e33d5b24556cb1e090ae9409ec61d"},
|
| 1037 |
+
{file = "fsspec-2025.10.0.tar.gz", hash = "sha256:b6789427626f068f9a83ca4e8a3cc050850b6c0f71f99ddb4f542b8266a26a59"},
|
| 1038 |
+
]
|
| 1039 |
+
|
| 1040 |
+
[package.extras]
|
| 1041 |
+
abfs = ["adlfs"]
|
| 1042 |
+
adl = ["adlfs"]
|
| 1043 |
+
arrow = ["pyarrow (>=1)"]
|
| 1044 |
+
dask = ["dask", "distributed"]
|
| 1045 |
+
dev = ["pre-commit", "ruff (>=0.5)"]
|
| 1046 |
+
doc = ["numpydoc", "sphinx", "sphinx-design", "sphinx-rtd-theme", "yarl"]
|
| 1047 |
+
dropbox = ["dropbox", "dropboxdrivefs", "requests"]
|
| 1048 |
+
full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "dask", "distributed", "dropbox", "dropboxdrivefs", "fusepy", "gcsfs", "libarchive-c", "ocifs", "panel", "paramiko", "pyarrow (>=1)", "pygit2", "requests", "s3fs", "smbprotocol", "tqdm"]
|
| 1049 |
+
fuse = ["fusepy"]
|
| 1050 |
+
gcs = ["gcsfs"]
|
| 1051 |
+
git = ["pygit2"]
|
| 1052 |
+
github = ["requests"]
|
| 1053 |
+
gs = ["gcsfs"]
|
| 1054 |
+
gui = ["panel"]
|
| 1055 |
+
hdfs = ["pyarrow (>=1)"]
|
| 1056 |
+
http = ["aiohttp (!=4.0.0a0,!=4.0.0a1)"]
|
| 1057 |
+
libarchive = ["libarchive-c"]
|
| 1058 |
+
oci = ["ocifs"]
|
| 1059 |
+
s3 = ["s3fs"]
|
| 1060 |
+
sftp = ["paramiko"]
|
| 1061 |
+
smb = ["smbprotocol"]
|
| 1062 |
+
ssh = ["paramiko"]
|
| 1063 |
+
test = ["aiohttp (!=4.0.0a0,!=4.0.0a1)", "numpy", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "requests"]
|
| 1064 |
+
test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask[dataframe,test]", "moto[server] (>4,<5)", "pytest-timeout", "xarray"]
|
| 1065 |
+
test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas", "panel", "paramiko", "pyarrow", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "smbprotocol", "tqdm", "urllib3", "zarr", "zstandard"]
|
| 1066 |
+
tqdm = ["tqdm"]
|
| 1067 |
+
|
| 1068 |
[[package]]
|
| 1069 |
name = "gitdb"
|
| 1070 |
version = "4.0.12"
|
|
|
|
| 1268 |
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
|
| 1269 |
]
|
| 1270 |
|
| 1271 |
+
[[package]]
|
| 1272 |
+
name = "hf-xet"
|
| 1273 |
+
version = "1.2.0"
|
| 1274 |
+
description = "Fast transfer of large files with the Hugging Face Hub."
|
| 1275 |
+
optional = false
|
| 1276 |
+
python-versions = ">=3.8"
|
| 1277 |
+
files = [
|
| 1278 |
+
{file = "hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649"},
|
| 1279 |
+
{file = "hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813"},
|
| 1280 |
+
{file = "hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc"},
|
| 1281 |
+
{file = "hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5"},
|
| 1282 |
+
{file = "hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f"},
|
| 1283 |
+
{file = "hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832"},
|
| 1284 |
+
{file = "hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382"},
|
| 1285 |
+
{file = "hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e"},
|
| 1286 |
+
{file = "hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8"},
|
| 1287 |
+
{file = "hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0"},
|
| 1288 |
+
{file = "hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090"},
|
| 1289 |
+
{file = "hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a"},
|
| 1290 |
+
{file = "hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f"},
|
| 1291 |
+
{file = "hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc"},
|
| 1292 |
+
{file = "hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848"},
|
| 1293 |
+
{file = "hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4"},
|
| 1294 |
+
{file = "hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd"},
|
| 1295 |
+
{file = "hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c"},
|
| 1296 |
+
{file = "hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737"},
|
| 1297 |
+
{file = "hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865"},
|
| 1298 |
+
{file = "hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69"},
|
| 1299 |
+
{file = "hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f"},
|
| 1300 |
+
]
|
| 1301 |
+
|
| 1302 |
+
[package.extras]
|
| 1303 |
+
tests = ["pytest"]
|
| 1304 |
+
|
| 1305 |
[[package]]
|
| 1306 |
name = "httpcore"
|
| 1307 |
version = "1.0.9"
|
|
|
|
| 1362 |
backends = ["redis (>=3.0.0)"]
|
| 1363 |
redis = ["redis (>=3.0.0)"]
|
| 1364 |
|
| 1365 |
+
[[package]]
|
| 1366 |
+
name = "huggingface-hub"
|
| 1367 |
+
version = "1.1.2"
|
| 1368 |
+
description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub"
|
| 1369 |
+
optional = false
|
| 1370 |
+
python-versions = ">=3.9.0"
|
| 1371 |
+
files = [
|
| 1372 |
+
{file = "huggingface_hub-1.1.2-py3-none-any.whl", hash = "sha256:dfcfa84a043466fac60573c3e4af475490a7b0d7375b22e3817706d6659f61f7"},
|
| 1373 |
+
{file = "huggingface_hub-1.1.2.tar.gz", hash = "sha256:7bdafc432dc12fa1f15211bdfa689a02531d2a47a3cc0d74935f5726cdbcab8e"},
|
| 1374 |
+
]
|
| 1375 |
+
|
| 1376 |
+
[package.dependencies]
|
| 1377 |
+
filelock = "*"
|
| 1378 |
+
fsspec = ">=2023.5.0"
|
| 1379 |
+
hf-xet = {version = ">=1.2.0,<2.0.0", markers = "platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"arm64\" or platform_machine == \"aarch64\""}
|
| 1380 |
+
httpx = ">=0.23.0,<1"
|
| 1381 |
+
packaging = ">=20.9"
|
| 1382 |
+
pyyaml = ">=5.1"
|
| 1383 |
+
shellingham = "*"
|
| 1384 |
+
tqdm = ">=4.42.1"
|
| 1385 |
+
typer-slim = "*"
|
| 1386 |
+
typing-extensions = ">=3.7.4.3"
|
| 1387 |
+
|
| 1388 |
+
[package.extras]
|
| 1389 |
+
all = ["Jinja2", "Pillow", "authlib (>=1.3.2)", "fastapi", "fastapi", "httpx", "itsdangerous", "jedi", "libcst (>=1.4.0)", "mypy (==1.15.0)", "numpy", "pytest (>=8.4.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (<16.0)", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "ty", "types-PyYAML", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"]
|
| 1390 |
+
dev = ["Jinja2", "Pillow", "authlib (>=1.3.2)", "fastapi", "fastapi", "httpx", "itsdangerous", "jedi", "libcst (>=1.4.0)", "mypy (==1.15.0)", "numpy", "pytest (>=8.4.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (<16.0)", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "ty", "types-PyYAML", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"]
|
| 1391 |
+
fastai = ["fastai (>=2.4)", "fastcore (>=1.3.27)", "toml"]
|
| 1392 |
+
hf-xet = ["hf-xet (>=1.1.3,<2.0.0)"]
|
| 1393 |
+
mcp = ["mcp (>=1.8.0)"]
|
| 1394 |
+
oauth = ["authlib (>=1.3.2)", "fastapi", "httpx", "itsdangerous"]
|
| 1395 |
+
quality = ["libcst (>=1.4.0)", "mypy (==1.15.0)", "ruff (>=0.9.0)", "ty"]
|
| 1396 |
+
testing = ["Jinja2", "Pillow", "authlib (>=1.3.2)", "fastapi", "fastapi", "httpx", "itsdangerous", "jedi", "numpy", "pytest (>=8.4.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (<16.0)", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"]
|
| 1397 |
+
torch = ["safetensors[torch]", "torch"]
|
| 1398 |
+
typing = ["types-PyYAML", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)"]
|
| 1399 |
+
|
| 1400 |
[[package]]
|
| 1401 |
name = "idna"
|
| 1402 |
version = "3.11"
|
|
|
|
| 3075 |
dev = ["abi3audit", "black", "check-manifest", "colorama", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pyreadline", "pytest", "pytest-cov", "pytest-instafail", "pytest-subtests", "pytest-xdist", "pywin32", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel", "wmi"]
|
| 3076 |
test = ["pytest", "pytest-instafail", "pytest-subtests", "pytest-xdist", "pywin32", "setuptools", "wheel", "wmi"]
|
| 3077 |
|
| 3078 |
+
[[package]]
|
| 3079 |
+
name = "psycopg"
|
| 3080 |
+
version = "3.2.12"
|
| 3081 |
+
description = "PostgreSQL database adapter for Python"
|
| 3082 |
+
optional = false
|
| 3083 |
+
python-versions = ">=3.8"
|
| 3084 |
+
files = [
|
| 3085 |
+
{file = "psycopg-3.2.12-py3-none-any.whl", hash = "sha256:8a1611a2d4c16ae37eada46438be9029a35bb959bb50b3d0e1e93c0f3d54c9ee"},
|
| 3086 |
+
{file = "psycopg-3.2.12.tar.gz", hash = "sha256:85c08d6f6e2a897b16280e0ff6406bef29b1327c045db06d21f364d7cd5da90b"},
|
| 3087 |
+
]
|
| 3088 |
+
|
| 3089 |
+
[package.dependencies]
|
| 3090 |
+
psycopg-binary = {version = "3.2.12", optional = true, markers = "implementation_name != \"pypy\" and extra == \"binary\""}
|
| 3091 |
+
typing-extensions = {version = ">=4.6", markers = "python_version < \"3.13\""}
|
| 3092 |
+
tzdata = {version = "*", markers = "sys_platform == \"win32\""}
|
| 3093 |
+
|
| 3094 |
+
[package.extras]
|
| 3095 |
+
binary = ["psycopg-binary (==3.2.12)"]
|
| 3096 |
+
c = ["psycopg-c (==3.2.12)"]
|
| 3097 |
+
dev = ["ast-comments (>=1.1.2)", "black (>=24.1.0)", "codespell (>=2.2)", "dnspython (>=2.1)", "flake8 (>=4.0)", "isort-psycopg", "isort[colors] (>=6.0)", "mypy (>=1.14)", "pre-commit (>=4.0.1)", "types-setuptools (>=57.4)", "types-shapely (>=2.0)", "wheel (>=0.37)"]
|
| 3098 |
+
docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)", "sphinx-autodoc-typehints (>=1.12)"]
|
| 3099 |
+
pool = ["psycopg-pool"]
|
| 3100 |
+
test = ["anyio (>=4.0)", "mypy (>=1.14)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"]
|
| 3101 |
+
|
| 3102 |
+
[[package]]
|
| 3103 |
+
name = "psycopg-binary"
|
| 3104 |
+
version = "3.2.12"
|
| 3105 |
+
description = "PostgreSQL database adapter for Python -- C optimisation distribution"
|
| 3106 |
+
optional = false
|
| 3107 |
+
python-versions = ">=3.8"
|
| 3108 |
+
files = [
|
| 3109 |
+
{file = "psycopg_binary-3.2.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:13cd057f406d2c8063ae8b489395b089a7f23c39aff223b5ea39f0c4dd640550"},
|
| 3110 |
+
{file = "psycopg_binary-3.2.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ef92d5ba6213de060d1390b1f71f5c3b2fbb00b4d55edee39f3b07234538b64a"},
|
| 3111 |
+
{file = "psycopg_binary-3.2.12-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:95f2806097a49bfd57e0c6a178f77b99487c53c157d9d507aee9c40dd58efdb4"},
|
| 3112 |
+
{file = "psycopg_binary-3.2.12-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ce68839da386f137bc8d814fdbeede8f89916b8605e3593a85b504a859243af9"},
|
| 3113 |
+
{file = "psycopg_binary-3.2.12-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:940ac69ef6e89c17b3d30f3297a2ad03efdd06a4b1857f81bc533a9108a90eb9"},
|
| 3114 |
+
{file = "psycopg_binary-3.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:310c95a68a9b948b89d6d187622757d57b6c26cece3c3f7c2cbb645ee36531b2"},
|
| 3115 |
+
{file = "psycopg_binary-3.2.12-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f7c81bc60560be9eb3c23601237765069ebfa9881097ce19ca6b5ea17c5faa8f"},
|
| 3116 |
+
{file = "psycopg_binary-3.2.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1c1dbeb8e97d00a33dfa9987776ce3d1c1e4cc251dfbd663b8f9e173f5c89d17"},
|
| 3117 |
+
{file = "psycopg_binary-3.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:8335d989a4e94df2ccd8a1acbba9d03c4157ea8d73b65b79d447c6dc10b001d8"},
|
| 3118 |
+
{file = "psycopg_binary-3.2.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:16db2549a31ccd4887bef05570d95036813ce25fd9810b523ba1c16b0f6cfd90"},
|
| 3119 |
+
{file = "psycopg_binary-3.2.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7b9a99ded7d19b24d3b6fa632b58e52bbdecde7e1f866c3b23d0c27b092af4e3"},
|
| 3120 |
+
{file = "psycopg_binary-3.2.12-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:385c7b5cfffac115f413b8e32c941c85ea0960e0b94a6ef43bb260f774c54893"},
|
| 3121 |
+
{file = "psycopg_binary-3.2.12-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9c674887d1e0d4384c06c822bc7fcfede4952742e232ec1e76b5a6ae39a3ddd4"},
|
| 3122 |
+
{file = "psycopg_binary-3.2.12-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72fd979e410ba7805462817ef8ed6f37dd75f9f4ae109bdb8503e013ccecb80b"},
|
| 3123 |
+
{file = "psycopg_binary-3.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec82fa5134517af44e28a30c38f34384773a0422ffd545fd298433ea9f2cc5a9"},
|
| 3124 |
+
{file = "psycopg_binary-3.2.12-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:100fdfee763d701f6da694bde711e264aca4c2bc84fb81e1669fb491ce11d219"},
|
| 3125 |
+
{file = "psycopg_binary-3.2.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:802bd01fb18a0acb0dea491f69a9a2da6034f33329a62876ab5b558a1fb66b45"},
|
| 3126 |
+
{file = "psycopg_binary-3.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:f33c9e12ed05e579b7fb3c8fdb10a165f41459394b8eb113e7c377b2bd027f61"},
|
| 3127 |
+
{file = "psycopg_binary-3.2.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ea9751310b840186379c949ede5a5129b31439acdb929f3003a8685372117ed8"},
|
| 3128 |
+
{file = "psycopg_binary-3.2.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9fdf3a0c24822401c60c93640da69b3dfd4d9f29c3a8d797244fe22bfe592823"},
|
| 3129 |
+
{file = "psycopg_binary-3.2.12-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49582c3b6d578bdaab2932b59f70b1bd93351ed4d594b2c97cea1611633c9de1"},
|
| 3130 |
+
{file = "psycopg_binary-3.2.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5b6e505618cb376a7a7d6af86833a8f289833fe4cc97541d7100745081dc31bd"},
|
| 3131 |
+
{file = "psycopg_binary-3.2.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6a898717ab560db393355c6ecf39b8c534f252afc3131480db1251e061090d3a"},
|
| 3132 |
+
{file = "psycopg_binary-3.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bfd632f7038c76b0921f6d5621f5ba9ecabfad3042fa40e5875db11771d2a5de"},
|
| 3133 |
+
{file = "psycopg_binary-3.2.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3e9c9e64fb7cda688e9488402611c0be2c81083664117edcc709d15f37faa30f"},
|
| 3134 |
+
{file = "psycopg_binary-3.2.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3c1e38b1eda54910628f68448598139a9818973755abf77950057372c1fe89a6"},
|
| 3135 |
+
{file = "psycopg_binary-3.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:77690f0bf08356ca00fc357f50a5980c7a25f076c2c1f37d9d775a278234fefd"},
|
| 3136 |
+
{file = "psycopg_binary-3.2.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:442f20153415f374ae5753ca618637611a41a3c58c56d16ce55f845d76a3cf7b"},
|
| 3137 |
+
{file = "psycopg_binary-3.2.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:79de3cc5adbf51677009a8fda35ac9e9e3686d5595ab4b0c43ec7099ece6aeb5"},
|
| 3138 |
+
{file = "psycopg_binary-3.2.12-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:095ccda59042a1239ac2fefe693a336cb5cecf8944a8d9e98b07f07e94e2b78d"},
|
| 3139 |
+
{file = "psycopg_binary-3.2.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:efab679a2c7d1bf7d0ec0e1ecb47fe764945eff75bb4321f2e699b30a12db9b3"},
|
| 3140 |
+
{file = "psycopg_binary-3.2.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d369e79ad9647fc8217cbb51bbbf11f9a1ffca450be31d005340157ffe8e91b3"},
|
| 3141 |
+
{file = "psycopg_binary-3.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eedc410f82007038030650aa58f620f9fe0009b9d6b04c3dc71cbd3bae5b2675"},
|
| 3142 |
+
{file = "psycopg_binary-3.2.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f3bae4be7f6781bf6c9576eedcd5e1bb74468126fa6de991e47cdb1a8ea3a42a"},
|
| 3143 |
+
{file = "psycopg_binary-3.2.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8ffe75fe6be902dadd439adf4228c98138a992088e073ede6dd34e7235f4e03e"},
|
| 3144 |
+
{file = "psycopg_binary-3.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:2598d0e4f2f258da13df0560187b3f1dfc9b8688c46b9d90176360ae5212c3fc"},
|
| 3145 |
+
{file = "psycopg_binary-3.2.12-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dc68094e00a5a7e8c20de1d3a0d5e404a27f522e18f8eb62bbbc9f865c3c81ef"},
|
| 3146 |
+
{file = "psycopg_binary-3.2.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2d55009eeddbef54c711093c986daaf361d2c4210aaa1ee905075a3b97a62441"},
|
| 3147 |
+
{file = "psycopg_binary-3.2.12-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:66a031f22e4418016990446d3e38143826f03ad811b9f78f58e2afbc1d343f7a"},
|
| 3148 |
+
{file = "psycopg_binary-3.2.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:58ed30d33c25d7dc8d2f06285e88493147c2a660cc94713e4b563a99efb80a1f"},
|
| 3149 |
+
{file = "psycopg_binary-3.2.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e0b5ccd03ca4749b8f66f38608ccbcb415cbd130d02de5eda80d042b83bee90e"},
|
| 3150 |
+
{file = "psycopg_binary-3.2.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:909de94de7dd4d6086098a5755562207114c9638ec42c52d84c8a440c45fe084"},
|
| 3151 |
+
{file = "psycopg_binary-3.2.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:7130effd0517881f3a852eff98729d51034128f0737f64f0d1c7ea8343d77bd7"},
|
| 3152 |
+
{file = "psycopg_binary-3.2.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89b3c5201ca616d69ca0c3c0003ca18f7170a679c445c7e386ebfb4f29aa738e"},
|
| 3153 |
+
{file = "psycopg_binary-3.2.12-cp314-cp314-win_amd64.whl", hash = "sha256:48a8e29f3e38fcf8d393b8fe460d83e39c107ad7e5e61cd3858a7569e0554a39"},
|
| 3154 |
+
{file = "psycopg_binary-3.2.12-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2aa80ca8d17266507bef853cecefa7d632ffd087883ee7ca92b8a7ea14a1e581"},
|
| 3155 |
+
{file = "psycopg_binary-3.2.12-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:deeb06b7141f3a577c3aa8562307e2747580ae43d705a0482603a2c1f110d046"},
|
| 3156 |
+
{file = "psycopg_binary-3.2.12-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:32b3e12d9441508f9c4e1424f4478b1a518a90a087cd54be3754e74954934194"},
|
| 3157 |
+
{file = "psycopg_binary-3.2.12-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d7cedecbe0bb60a2e72b1613fba4072a184a6472d6cc9aa99e540217f544e3e"},
|
| 3158 |
+
{file = "psycopg_binary-3.2.12-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ea049c8d33c4f4e6b030d5a68123c0ccd2ffb77d4035f073db97187b49b6422f"},
|
| 3159 |
+
{file = "psycopg_binary-3.2.12-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f821e0c8a8fdfddfa71acb4f462d7a4c5aae1655f3f5e078970dbe9f19027386"},
|
| 3160 |
+
{file = "psycopg_binary-3.2.12-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ef40601b959cc1440deaf4d53472ab54fa51036c37189cf3fe5500559ac25347"},
|
| 3161 |
+
{file = "psycopg_binary-3.2.12-cp38-cp38-win_amd64.whl", hash = "sha256:0afb71a99871a41dd677d207c6a988d978edde5d6a018bafaed4f9da45357055"},
|
| 3162 |
+
{file = "psycopg_binary-3.2.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f8107968a9eadb451cfa6cf86036006fdde32a83cd39c26c9ca46765e653b547"},
|
| 3163 |
+
{file = "psycopg_binary-3.2.12-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:15e226f0d8af85cc8b2435b2e9bc6f0d40febc79eef76cf20fceac4d902a6a7b"},
|
| 3164 |
+
{file = "psycopg_binary-3.2.12-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6ba1fe35fd215813dac4544a5ffc90f13713b29dd26e9e5be97ba53482bf6d6"},
|
| 3165 |
+
{file = "psycopg_binary-3.2.12-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:26b5927b5880b396231ab6190ee5c8fb47ed3f459b53504ed5419faaf16d3bfb"},
|
| 3166 |
+
{file = "psycopg_binary-3.2.12-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab02b7d138768fd6ac4230e45b073f7b9fd688d88c04f24c34df4a250a94d066"},
|
| 3167 |
+
{file = "psycopg_binary-3.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:acb1811219a4144539f0baee224a11a2aa323a739c349799cf52f191eb87bc52"},
|
| 3168 |
+
{file = "psycopg_binary-3.2.12-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:356b4266e5cde7b5bbcf232f549dedf7fbed4983daa556042bdec397780e044d"},
|
| 3169 |
+
{file = "psycopg_binary-3.2.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:489b154891f1c995355adeb1077ee3479e9c9bada721b93270c20243bbad6542"},
|
| 3170 |
+
{file = "psycopg_binary-3.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:294f08b014f08dfd3c9b72408f5e1a0fd187bd86d7a85ead651e32dbd47aa038"},
|
| 3171 |
+
]
|
| 3172 |
+
|
| 3173 |
[[package]]
|
| 3174 |
name = "ptyprocess"
|
| 3175 |
version = "0.7.0"
|
|
|
|
| 3440 |
[package.dependencies]
|
| 3441 |
typing-extensions = ">=4.14.1"
|
| 3442 |
|
| 3443 |
+
[[package]]
|
| 3444 |
+
name = "pydantic-settings"
|
| 3445 |
+
version = "2.11.0"
|
| 3446 |
+
description = "Settings management using Pydantic"
|
| 3447 |
+
optional = false
|
| 3448 |
+
python-versions = ">=3.9"
|
| 3449 |
+
files = [
|
| 3450 |
+
{file = "pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c"},
|
| 3451 |
+
{file = "pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180"},
|
| 3452 |
+
]
|
| 3453 |
+
|
| 3454 |
+
[package.dependencies]
|
| 3455 |
+
pydantic = ">=2.7.0"
|
| 3456 |
+
python-dotenv = ">=0.21.0"
|
| 3457 |
+
typing-inspection = ">=0.4.0"
|
| 3458 |
+
|
| 3459 |
+
[package.extras]
|
| 3460 |
+
aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"]
|
| 3461 |
+
azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"]
|
| 3462 |
+
gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"]
|
| 3463 |
+
toml = ["tomli (>=2.0.1)"]
|
| 3464 |
+
yaml = ["pyyaml (>=6.0.1)"]
|
| 3465 |
+
|
| 3466 |
[[package]]
|
| 3467 |
name = "pygments"
|
| 3468 |
version = "2.19.2"
|
|
|
|
| 4244 |
test-core = ["mypy", "pytest", "pytest-cov", "pytest-mpl"]
|
| 4245 |
test-notebooks = ["datasets", "jupyter", "keras", "nbconvert", "nbformat", "nlp", "transformers"]
|
| 4246 |
|
| 4247 |
+
[[package]]
|
| 4248 |
+
name = "shellingham"
|
| 4249 |
+
version = "1.5.4"
|
| 4250 |
+
description = "Tool to Detect Surrounding Shell"
|
| 4251 |
+
optional = false
|
| 4252 |
+
python-versions = ">=3.7"
|
| 4253 |
+
files = [
|
| 4254 |
+
{file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"},
|
| 4255 |
+
{file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"},
|
| 4256 |
+
]
|
| 4257 |
+
|
| 4258 |
[[package]]
|
| 4259 |
name = "six"
|
| 4260 |
version = "1.17.0"
|
|
|
|
| 4564 |
docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"]
|
| 4565 |
test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"]
|
| 4566 |
|
| 4567 |
+
[[package]]
|
| 4568 |
+
name = "typer-slim"
|
| 4569 |
+
version = "0.20.0"
|
| 4570 |
+
description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
|
| 4571 |
+
optional = false
|
| 4572 |
+
python-versions = ">=3.8"
|
| 4573 |
+
files = [
|
| 4574 |
+
{file = "typer_slim-0.20.0-py3-none-any.whl", hash = "sha256:f42a9b7571a12b97dddf364745d29f12221865acef7a2680065f9bb29c7dc89d"},
|
| 4575 |
+
{file = "typer_slim-0.20.0.tar.gz", hash = "sha256:9fc6607b3c6c20f5c33ea9590cbeb17848667c51feee27d9e314a579ab07d1a3"},
|
| 4576 |
+
]
|
| 4577 |
+
|
| 4578 |
+
[package.dependencies]
|
| 4579 |
+
click = ">=8.0.0"
|
| 4580 |
+
typing-extensions = ">=3.7.4.3"
|
| 4581 |
+
|
| 4582 |
+
[package.extras]
|
| 4583 |
+
standard = ["rich (>=10.11.0)", "shellingham (>=1.3.0)"]
|
| 4584 |
+
|
| 4585 |
[[package]]
|
| 4586 |
name = "typing-extensions"
|
| 4587 |
version = "4.15.0"
|
|
|
|
| 4770 |
[metadata]
|
| 4771 |
lock-version = "2.0"
|
| 4772 |
python-versions = "^3.12"
|
| 4773 |
+
content-hash = "1b1a0392b846da9cf6def7d64ea811aabe4e7e66028e7655a520764a383a3346"
|
pyproject.toml
CHANGED
|
@@ -20,6 +20,13 @@ notebook = "^7.4.7"
|
|
| 20 |
ipykernel = "^6.30.1"
|
| 21 |
missingno = "^0.5.2"
|
| 22 |
lightgbm = "^4.6.0"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
[build-system]
|
|
|
|
| 20 |
ipykernel = "^6.30.1"
|
| 21 |
missingno = "^0.5.2"
|
| 22 |
lightgbm = "^4.6.0"
|
| 23 |
+
fastapi = "^0.121.1"
|
| 24 |
+
pydantic = "^2.12.4"
|
| 25 |
+
sqlalchemy = "^2.0.44"
|
| 26 |
+
pydantic-settings = "^2.11.0"
|
| 27 |
+
psycopg = {extras = ["binary"], version = "^3.2.12"}
|
| 28 |
+
huggingface-hub = "^1.1.2"
|
| 29 |
+
dotenv = "^0.9.9"
|
| 30 |
|
| 31 |
|
| 32 |
[build-system]
|
src/config/db.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine
|
| 2 |
+
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
| 3 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 4 |
+
|
| 5 |
+
class Settings(BaseSettings):
|
| 6 |
+
DATABASE_URL: str
|
| 7 |
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore",)
|
| 8 |
+
|
| 9 |
+
settings = Settings()
|
| 10 |
+
|
| 11 |
+
class Base(DeclarativeBase):
|
| 12 |
+
pass
|
| 13 |
+
|
| 14 |
+
engine = create_engine(settings.DATABASE_URL, echo=True, future=True)
|
| 15 |
+
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
| 16 |
+
|
| 17 |
+
def get_db():
|
| 18 |
+
db = SessionLocal()
|
| 19 |
+
try:
|
| 20 |
+
yield db
|
| 21 |
+
finally:
|
| 22 |
+
db.close()
|
src/controllers/home_controller.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
|
| 8 |
+
from config.db import get_db
|
| 9 |
+
from models.ml import MLModel
|
| 10 |
+
|
| 11 |
+
router = APIRouter(tags=["Models"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class MLModelOut(BaseModel):
|
| 15 |
+
id: str = Field(..., description="Identifiant unique du modèle (UUID en chaîne).")
|
| 16 |
+
name: str = Field(..., description="Nom court du modèle.")
|
| 17 |
+
description: Optional[str] = Field(None, description="Description du modèle.")
|
| 18 |
+
created_at: Optional[datetime] = Field(
|
| 19 |
+
None, description="Date de création du modèle (UTC, ISO 8601)."
|
| 20 |
+
)
|
| 21 |
+
is_active: bool = Field(..., description="Modèle actif/inactif.")
|
| 22 |
+
model_config = {"json_schema_extra": {
|
| 23 |
+
"examples": [{
|
| 24 |
+
"id": "5b1c7b3a-0000-4000-8000-000000000002",
|
| 25 |
+
"name": "best_model",
|
| 26 |
+
"description": "XGB v1",
|
| 27 |
+
"created_at": "2025-09-15T10:11:03.950802+00:00",
|
| 28 |
+
"is_active": True
|
| 29 |
+
}]
|
| 30 |
+
}}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@router.get(
|
| 34 |
+
"/",
|
| 35 |
+
response_model=List[MLModelOut],
|
| 36 |
+
status_code=status.HTTP_200_OK,
|
| 37 |
+
summary="Lister les modèles ML",
|
| 38 |
+
description=(
|
| 39 |
+
"Retourne la liste des modèles disponibles, triés du plus récent au plus ancien.\n\n"
|
| 40 |
+
"**Remarques**\n"
|
| 41 |
+
"- Les champs sont mappés depuis la table `ml_models`.\n"
|
| 42 |
+
),
|
| 43 |
+
responses={
|
| 44 |
+
200: {
|
| 45 |
+
"description": "Liste des modèles.",
|
| 46 |
+
"content": {
|
| 47 |
+
"application/json": {
|
| 48 |
+
"example": [
|
| 49 |
+
{
|
| 50 |
+
"id": "5b1c7b3a-0000-4000-8000-000000000002",
|
| 51 |
+
"name": "best_model",
|
| 52 |
+
"description": "XGB v1",
|
| 53 |
+
"created_at": "2025-09-15T10:11:03.950802+00:00",
|
| 54 |
+
"is_active": True
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"id": "5b1c7b3a-0000-4000-8000-000000000001",
|
| 58 |
+
"name": "baseline",
|
| 59 |
+
"description": "Baseline model",
|
| 60 |
+
"created_at": "2025-09-15T10:11:03.950802+00:00",
|
| 61 |
+
"is_active": True
|
| 62 |
+
}
|
| 63 |
+
]
|
| 64 |
+
}
|
| 65 |
+
},
|
| 66 |
+
},
|
| 67 |
+
500: {"description": "Erreur serveur lors de la lecture des modèles."},
|
| 68 |
+
},
|
| 69 |
+
)
|
| 70 |
+
def list_ml_models(db: Session = Depends(get_db)) -> List[MLModelOut]:
|
| 71 |
+
try:
|
| 72 |
+
rows = (
|
| 73 |
+
db.query(MLModel)
|
| 74 |
+
.order_by(MLModel.created_at.desc())
|
| 75 |
+
.all()
|
| 76 |
+
)
|
| 77 |
+
return [
|
| 78 |
+
MLModelOut(
|
| 79 |
+
id=str(r.id),
|
| 80 |
+
name=r.name,
|
| 81 |
+
description=r.description,
|
| 82 |
+
created_at=r.created_at,
|
| 83 |
+
is_active=r.is_active,
|
| 84 |
+
)
|
| 85 |
+
for r in rows
|
| 86 |
+
]
|
| 87 |
+
except Exception as e:
|
| 88 |
+
raise HTTPException(status_code=500, detail=str(e))
|
src/controllers/predict_controller.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, Body, status
|
| 2 |
+
|
| 3 |
+
from config.db import get_db
|
| 4 |
+
from models.ml import MLModel
|
| 5 |
+
|
| 6 |
+
from models.ml_inputs import MLInput
|
| 7 |
+
from models.ml_output import MLOutput
|
| 8 |
+
|
| 9 |
+
import pandas as pd
|
| 10 |
+
from model_loader import load_model
|
| 11 |
+
from features import compute_features
|
| 12 |
+
from schemas.PredictItemResult import PredictItemResult
|
| 13 |
+
from schemas.PredictResponse import PredictResponse
|
| 14 |
+
from schemas.PredictRequest import PredictRequest
|
| 15 |
+
from sqlalchemy.orm import Session
|
| 16 |
+
|
| 17 |
+
router = APIRouter(prefix="/predict", tags=["Prédiction"])
|
| 18 |
+
|
| 19 |
+
LABELS = {
|
| 20 |
+
"0": "reste_dans_l_entreprise",
|
| 21 |
+
"1": "parti_de_l_entreprise",
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
@router.post(
|
| 25 |
+
"/",
|
| 26 |
+
response_model=PredictResponse,
|
| 27 |
+
status_code=status.HTTP_200_OK,
|
| 28 |
+
summary="Prédire l’attrition d’un employé",
|
| 29 |
+
description=(
|
| 30 |
+
"Calcule la probabilité d’attrition pour chaque entrée fournie.\n\n"
|
| 31 |
+
"**Notes**\n"
|
| 32 |
+
"- `model_name` doit référencer un modèle *actif* en base (`MLModel`).\n"
|
| 33 |
+
"- Les données d’entrée sont persistées (`MLInput`) puis les sorties (`MLOutput`) sont enregistrées.\n"
|
| 34 |
+
"- En cas d’erreur de features ou de prédiction, la requête retourne **400**.\n"
|
| 35 |
+
),
|
| 36 |
+
responses={
|
| 37 |
+
200: {"description": "Prédictions calculées avec succès."},
|
| 38 |
+
400: {"description": "Erreur pendant la préparation des features ou la prédiction."},
|
| 39 |
+
404: {"description": "Modèle introuvable ou inactif."},
|
| 40 |
+
500: {"description": "Impossible de charger le modèle/erreur serveur."},
|
| 41 |
+
},
|
| 42 |
+
)
|
| 43 |
+
def batch_predict(
|
| 44 |
+
payload: PredictRequest = Body(
|
| 45 |
+
...,
|
| 46 |
+
examples={
|
| 47 |
+
"cas-minimal": {
|
| 48 |
+
"summary": "Exemple minimal",
|
| 49 |
+
"value": {
|
| 50 |
+
"model_name": "best_model",
|
| 51 |
+
"inputs": [
|
| 52 |
+
{
|
| 53 |
+
"id_employee": 123,
|
| 54 |
+
"age": 35,
|
| 55 |
+
"genre": "Homme",
|
| 56 |
+
"revenu_mensuel": 4200
|
| 57 |
+
}
|
| 58 |
+
],
|
| 59 |
+
},
|
| 60 |
+
},
|
| 61 |
+
"cas-complet": {
|
| 62 |
+
"summary": "Exemple complet",
|
| 63 |
+
"value": {
|
| 64 |
+
"model_name": "best_model",
|
| 65 |
+
"inputs": [
|
| 66 |
+
{
|
| 67 |
+
"id_employee": 123,
|
| 68 |
+
"age": 35,
|
| 69 |
+
"genre": "Homme",
|
| 70 |
+
"revenu_mensuel": 4200,
|
| 71 |
+
"statut_marital": "Célibataire",
|
| 72 |
+
"departement": "Ventes",
|
| 73 |
+
"poste": "Commercial",
|
| 74 |
+
"nombre_experiences_precedentes": 2,
|
| 75 |
+
"nombre_heures_travailless": 40,
|
| 76 |
+
"annee_experience_totale": 5,
|
| 77 |
+
"annees_dans_l_entreprise": 2,
|
| 78 |
+
"annees_dans_le_poste_actuel": 1,
|
| 79 |
+
"nombre_participation_pee": 1,
|
| 80 |
+
"nb_formations_suivies": 3,
|
| 81 |
+
"nombre_employee_sous_responsabilite": 0,
|
| 82 |
+
"code_sondage": 7,
|
| 83 |
+
"distance_domicile_travail": 12,
|
| 84 |
+
"niveau_education": 3,
|
| 85 |
+
"domaine_etude": "Marketing",
|
| 86 |
+
"ayant_enfants": "Non",
|
| 87 |
+
"frequence_deplacement": "Rarement",
|
| 88 |
+
"annees_depuis_la_derniere_promotion": 0,
|
| 89 |
+
"annes_sous_responsable_actuel": 1,
|
| 90 |
+
"satisfaction_employee_environnement": 3,
|
| 91 |
+
"note_evaluation_precedente": 4,
|
| 92 |
+
"niveau_hierarchique_poste": 2,
|
| 93 |
+
"satisfaction_employee_nature_travail": 3,
|
| 94 |
+
"satisfaction_employee_equipe": 4,
|
| 95 |
+
"satisfaction_employee_equilibre_pro_perso": 3,
|
| 96 |
+
"eval_number": "E2",
|
| 97 |
+
"note_evaluation_actuelle": 4,
|
| 98 |
+
"heure_supplementaires": "Non",
|
| 99 |
+
"augementation_salaire_precedente": 11
|
| 100 |
+
}
|
| 101 |
+
],
|
| 102 |
+
},
|
| 103 |
+
},
|
| 104 |
+
},
|
| 105 |
+
),
|
| 106 |
+
db: Session = Depends(get_db),
|
| 107 |
+
):
|
| 108 |
+
row = (
|
| 109 |
+
db.query(MLModel)
|
| 110 |
+
.filter(MLModel.name == payload.model_name)
|
| 111 |
+
.first()
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
objs = [MLInput(**x.model_dump()) for x in payload.inputs]
|
| 115 |
+
db.add_all(objs)
|
| 116 |
+
db.commit()
|
| 117 |
+
|
| 118 |
+
if not row or getattr(row, "is_active", True) is False:
|
| 119 |
+
raise HTTPException(status_code=404, detail="Modèle introuvable ou inactif")
|
| 120 |
+
|
| 121 |
+
try:
|
| 122 |
+
m = load_model(payload.model_name)
|
| 123 |
+
except Exception as e:
|
| 124 |
+
raise HTTPException(
|
| 125 |
+
status_code=500,
|
| 126 |
+
detail=f"Chargement du modèle '{payload.model_name}' impossible: {e}",
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
df = pd.DataFrame([x.model_dump() for x in payload.inputs])
|
| 131 |
+
X = compute_features(df)
|
| 132 |
+
|
| 133 |
+
results: list[PredictItemResult] = []
|
| 134 |
+
|
| 135 |
+
probas = m.predict_proba(X)
|
| 136 |
+
classes = getattr(m, "classes_", None)
|
| 137 |
+
|
| 138 |
+
for idx, p in enumerate(probas):
|
| 139 |
+
i = int(p.argmax())
|
| 140 |
+
key = str(classes[i]) if classes is not None else str(i)
|
| 141 |
+
label = LABELS.get(key, key)
|
| 142 |
+
|
| 143 |
+
pred = PredictItemResult(label=label, proba=float(p[i]))
|
| 144 |
+
results.append(pred)
|
| 145 |
+
|
| 146 |
+
db.add(
|
| 147 |
+
MLOutput(
|
| 148 |
+
input_id=objs[idx].id,
|
| 149 |
+
prediction=label,
|
| 150 |
+
prob=float(p[i]),
|
| 151 |
+
)
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
db.commit()
|
| 155 |
+
|
| 156 |
+
except Exception as e:
|
| 157 |
+
db.rollback()
|
| 158 |
+
raise HTTPException(status_code=400, detail=f"Erreur pendant la prédiction: {e}")
|
| 159 |
+
|
| 160 |
+
return PredictResponse(
|
| 161 |
+
model_name=payload.model_name,
|
| 162 |
+
results=results,
|
| 163 |
+
)
|
src/features.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pandas as pd
|
| 3 |
+
|
| 4 |
+
def _safe_div(a, b):
|
| 5 |
+
a = pd.to_numeric(a, errors="coerce")
|
| 6 |
+
b = pd.to_numeric(b, errors="coerce").replace(0, np.nan)
|
| 7 |
+
return (a / b).fillna(0.0)
|
| 8 |
+
|
| 9 |
+
def compute_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 10 |
+
SAT_COLS = [
|
| 11 |
+
"satisfaction_employee_environnement",
|
| 12 |
+
"satisfaction_employee_nature_travail",
|
| 13 |
+
"satisfaction_employee_equipe",
|
| 14 |
+
"satisfaction_employee_equilibre_pro_perso",
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
X = df.copy()
|
| 18 |
+
X["sat_mean"] = X[SAT_COLS].astype(float).mean(axis=1)
|
| 19 |
+
X["sat_std"] = X[SAT_COLS].astype(float).std(axis=1, ddof=0)
|
| 20 |
+
X["delta_eval"] = (
|
| 21 |
+
X["note_evaluation_actuelle"].astype(float)
|
| 22 |
+
- X["note_evaluation_precedente"].astype(float)
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
X["ratio_post_stab"] = _safe_div(X["annes_sous_responsable_actuel"], X["annees_dans_le_poste_actuel"])
|
| 26 |
+
X["revenu_par_niveau"] = _safe_div(X["revenu_mensuel"], X["niveau_hierarchique_poste"])
|
| 27 |
+
|
| 28 |
+
age_bins = [-np.inf, 25, 35, 45, 60, np.inf]
|
| 29 |
+
dist_bins = [-np.inf, 5, 10, 20, np.inf]
|
| 30 |
+
revenu_bins = [-np.inf, 2500, 4000, 6000, np.inf]
|
| 31 |
+
sat_mean_bins = [-np.inf, 2.0, 3.0, 4.0, np.inf]
|
| 32 |
+
|
| 33 |
+
X["tranche_age"] = pd.cut(X["age"].astype(float), age_bins, labels=["<=25","26-35","36-45","46-60","60+"])
|
| 34 |
+
X["tranche_distance"] = pd.cut(X["distance_domicile_travail"].astype(float), dist_bins, labels=["<=5","6-10","11-20",">20"])
|
| 35 |
+
X["tranche_revenu"] = pd.cut(X["revenu_mensuel"].astype(float), revenu_bins, labels=["<=2.5k","2.5-4k","4-6k",">6k"])
|
| 36 |
+
X["tranche_sat_mean"] = pd.cut(X["sat_mean"], sat_mean_bins, labels=["basse","moyenne","bonne","excellente"])
|
| 37 |
+
|
| 38 |
+
return X
|
src/main.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from fastapi import FastAPI
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
from controllers.home_controller import router as ml_home_router
|
| 6 |
+
from controllers.predict_controller import router as predict_router
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
app = FastAPI(title="ML API",
|
| 10 |
+
description="""
|
| 11 |
+
API d’inférence pour la prédiction d’attrition.
|
| 12 |
+
- **/predict**: prédire un résultat selon le modèle
|
| 13 |
+
- **/models**: lister les modèles disponibles
|
| 14 |
+
""", version="1.0.0")
|
| 15 |
+
|
| 16 |
+
app.include_router(ml_home_router)
|
| 17 |
+
|
| 18 |
+
app.include_router(predict_router)
|
src/model_loader.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from functools import lru_cache
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Any, Literal
|
| 5 |
+
from huggingface_hub import hf_hub_download
|
| 6 |
+
import joblib
|
| 7 |
+
|
| 8 |
+
HF_REPO_ID = os.getenv("HF_REPO_ID", "Marintosti/attrition")
|
| 9 |
+
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 10 |
+
|
| 11 |
+
ENV: Literal["dev", "test", "prod"] = os.getenv("APP_ENV", "dev").lower()
|
| 12 |
+
ARTIFACTS_DIR = Path(os.getenv("ARTIFACTS_DIR", "artifacts"))
|
| 13 |
+
|
| 14 |
+
def _load_local(name: str) -> Any:
|
| 15 |
+
path = ARTIFACTS_DIR / f"{name}.joblib"
|
| 16 |
+
if not path.exists():
|
| 17 |
+
raise FileNotFoundError(
|
| 18 |
+
f"Modèle local introuvable: {path}. "
|
| 19 |
+
)
|
| 20 |
+
return joblib.load(path)
|
| 21 |
+
|
| 22 |
+
@lru_cache(maxsize=1)
|
| 23 |
+
def load_model(name) -> Any:
|
| 24 |
+
|
| 25 |
+
if ENV in ("dev"):
|
| 26 |
+
return _load_local(name)
|
| 27 |
+
|
| 28 |
+
hf_path = hf_hub_download(
|
| 29 |
+
repo_id=HF_REPO_ID,
|
| 30 |
+
filename=f"{name}.joblib",
|
| 31 |
+
token=HF_TOKEN,
|
| 32 |
+
local_files_only=False,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
return joblib.load(hf_path)
|
src/schemas/ModelFeatures.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
|
| 3 |
+
class ModelFeatures(BaseModel):
|
| 4 |
+
id_employee: int
|
| 5 |
+
age: int
|
| 6 |
+
genre: str
|
| 7 |
+
revenu_mensuel: int
|
| 8 |
+
statut_marital: str
|
| 9 |
+
departement: str
|
| 10 |
+
poste: str
|
| 11 |
+
nombre_experiences_precedentes: int
|
| 12 |
+
nombre_heures_travailless: int
|
| 13 |
+
annee_experience_totale: int
|
| 14 |
+
annees_dans_l_entreprise: int
|
| 15 |
+
annees_dans_le_poste_actuel: int
|
| 16 |
+
nombre_participation_pee: int
|
| 17 |
+
nb_formations_suivies: int
|
| 18 |
+
nombre_employee_sous_responsabilite: int
|
| 19 |
+
code_sondage: int
|
| 20 |
+
distance_domicile_travail: int
|
| 21 |
+
niveau_education: int
|
| 22 |
+
domaine_etude: str
|
| 23 |
+
ayant_enfants: str
|
| 24 |
+
frequence_deplacement: str
|
| 25 |
+
annees_depuis_la_derniere_promotion: int
|
| 26 |
+
annes_sous_responsable_actuel: int
|
| 27 |
+
satisfaction_employee_environnement: int
|
| 28 |
+
note_evaluation_precedente: int
|
| 29 |
+
niveau_hierarchique_poste: int
|
| 30 |
+
satisfaction_employee_nature_travail: int
|
| 31 |
+
satisfaction_employee_equipe: int
|
| 32 |
+
satisfaction_employee_equilibre_pro_perso: int
|
| 33 |
+
eval_number: str
|
| 34 |
+
note_evaluation_actuelle: int
|
| 35 |
+
heure_supplementaires: str
|
| 36 |
+
augementation_salaire_precedente: int
|
src/schemas/PredictItemResult.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class PredictItemResult(BaseModel):
|
| 6 |
+
label: str
|
| 7 |
+
proba: Optional[float] = None
|
src/schemas/PredictRequest.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
|
| 4 |
+
from schemas.ModelFeatures import ModelFeatures
|
| 5 |
+
|
| 6 |
+
class PredictRequest(BaseModel):
|
| 7 |
+
model_name: str
|
| 8 |
+
inputs: List[ModelFeatures]
|
src/schemas/PredictResponse.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
|
| 4 |
+
from schemas.PredictItemResult import PredictItemResult
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class PredictResponse(BaseModel):
|
| 8 |
+
model_name: str
|
| 9 |
+
results: List[PredictItemResult]
|
src/schemas/__init__.py
ADDED
|
File without changes
|
src/seeds/ml_models_seed.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
from sqlalchemy import create_engine, text
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
|
| 6 |
+
try:
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
load_dotenv()
|
| 9 |
+
except Exception:
|
| 10 |
+
pass
|
| 11 |
+
|
| 12 |
+
DATABASE_URL = os.environ["DATABASE_URL"]
|
| 13 |
+
engine = create_engine(DATABASE_URL, future=True)
|
| 14 |
+
|
| 15 |
+
UPSERT = text("""
|
| 16 |
+
INSERT INTO ml_models (id, name, description, created_at, is_active)
|
| 17 |
+
VALUES (:id, :name, :description, :created_at, :is_active)
|
| 18 |
+
ON CONFLICT (name) DO UPDATE
|
| 19 |
+
SET description = EXCLUDED.description,
|
| 20 |
+
is_active = EXCLUDED.is_active
|
| 21 |
+
""")
|
| 22 |
+
|
| 23 |
+
def seed_ml_models(session: Session):
|
| 24 |
+
rows = [
|
| 25 |
+
{"id": "5b1c7b3a-0000-4000-8000-000000000001", "name": "baseline", "description": "Baseline model", "is_active": True},
|
| 26 |
+
{"id": "5b1c7b3a-0000-4000-8000-000000000002", "name": "best_model", "description": "Best model", "is_active": False},
|
| 27 |
+
]
|
| 28 |
+
now = datetime.now(timezone.utc)
|
| 29 |
+
for r in rows:
|
| 30 |
+
session.execute(UPSERT, {**r, "created_at": now})
|
| 31 |
+
|
| 32 |
+
def main():
|
| 33 |
+
with Session(engine) as s:
|
| 34 |
+
seed_ml_models(s)
|
| 35 |
+
s.commit()
|
| 36 |
+
|
| 37 |
+
if __name__ == "__main__":
|
| 38 |
+
main()
|
tests/functional/test_home.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi.testclient import TestClient
|
| 2 |
+
from sqlalchemy import create_engine
|
| 3 |
+
from sqlalchemy.orm import sessionmaker
|
| 4 |
+
|
| 5 |
+
from main import app
|
| 6 |
+
from config.db import get_db
|
| 7 |
+
|
| 8 |
+
from models.ml import MLModel
|
| 9 |
+
|
| 10 |
+
import uuid
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_list_models_simple(tmp_path):
|
| 15 |
+
db_path = tmp_path / "testing.db"
|
| 16 |
+
engine = create_engine(
|
| 17 |
+
f"sqlite:///{db_path}",
|
| 18 |
+
connect_args={"check_same_thread": False},
|
| 19 |
+
future=True,
|
| 20 |
+
)
|
| 21 |
+
SQLSession = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
| 22 |
+
|
| 23 |
+
MLModel.metadata.create_all(engine)
|
| 24 |
+
|
| 25 |
+
session = SQLSession()
|
| 26 |
+
|
| 27 |
+
def get_db_override():
|
| 28 |
+
return session
|
| 29 |
+
|
| 30 |
+
app.dependency_overrides[get_db] = get_db_override
|
| 31 |
+
|
| 32 |
+
client = TestClient(app, raise_server_exceptions=False)
|
| 33 |
+
|
| 34 |
+
created = datetime(2025, 9, 15, 10, 11, 3, 950802, tzinfo=timezone.utc)
|
| 35 |
+
session.add_all(
|
| 36 |
+
[
|
| 37 |
+
MLModel(
|
| 38 |
+
id=uuid.UUID("5b1c7b3a-0000-4000-8000-000000000001"),
|
| 39 |
+
name="baseline",
|
| 40 |
+
description="Baseline model",
|
| 41 |
+
created_at=created,
|
| 42 |
+
is_active=True,
|
| 43 |
+
),
|
| 44 |
+
MLModel(
|
| 45 |
+
id=uuid.UUID("5b1c7b3a-0000-4000-8000-000000000002"),
|
| 46 |
+
name="best_model",
|
| 47 |
+
description="XGB v1",
|
| 48 |
+
created_at=created,
|
| 49 |
+
is_active=True,
|
| 50 |
+
),
|
| 51 |
+
MLModel(
|
| 52 |
+
id=uuid.UUID("5b1c7b3a-0000-4000-8000-000000000003"),
|
| 53 |
+
name="logistic_regression",
|
| 54 |
+
description="Logistic Regression",
|
| 55 |
+
created_at=created,
|
| 56 |
+
is_active=True,
|
| 57 |
+
),
|
| 58 |
+
]
|
| 59 |
+
)
|
| 60 |
+
session.commit()
|
| 61 |
+
|
| 62 |
+
resp = client.get("/")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
app.dependency_overrides.clear()
|
| 66 |
+
session.close()
|
| 67 |
+
|
| 68 |
+
assert resp.status_code == 200
|
| 69 |
+
data = resp.json()
|
| 70 |
+
names = {row["name"] for row in data}
|
| 71 |
+
assert names == {"baseline", "best_model", 'logistic_regression'}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_list_models_returns_500_when_db_fails():
|
| 75 |
+
class BrokenSession:
|
| 76 |
+
def query(self, *a, **kw):
|
| 77 |
+
raise RuntimeError("DB is down")
|
| 78 |
+
|
| 79 |
+
def get_db_override():
|
| 80 |
+
yield BrokenSession()
|
| 81 |
+
|
| 82 |
+
app.dependency_overrides[get_db] = get_db_override
|
| 83 |
+
client = TestClient(app, raise_server_exceptions=False)
|
| 84 |
+
|
| 85 |
+
resp = client.get("/")
|
| 86 |
+
|
| 87 |
+
app.dependency_overrides.clear()
|
| 88 |
+
|
| 89 |
+
assert resp.status_code == 500
|
| 90 |
+
body = resp.json()
|
| 91 |
+
assert "DB is down" in body["detail"]
|
| 92 |
+
|
tests/functional/test_predict.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi.testclient import TestClient
|
| 2 |
+
from sqlalchemy import create_engine
|
| 3 |
+
from sqlalchemy.orm import sessionmaker
|
| 4 |
+
|
| 5 |
+
from main import app
|
| 6 |
+
from config.db import get_db
|
| 7 |
+
|
| 8 |
+
from models.ml import MLModel
|
| 9 |
+
from models.ml_inputs import MLInput
|
| 10 |
+
from models.ml_output import MLOutput
|
| 11 |
+
|
| 12 |
+
import uuid
|
| 13 |
+
from datetime import datetime, timezone
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_simple_predict(tmp_path):
|
| 17 |
+
db_path = tmp_path / "testing.db"
|
| 18 |
+
engine = create_engine(
|
| 19 |
+
f"sqlite:///{db_path}",
|
| 20 |
+
connect_args={"check_same_thread": False},
|
| 21 |
+
future=True,
|
| 22 |
+
)
|
| 23 |
+
SQLSession = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
| 24 |
+
|
| 25 |
+
MLModel.metadata.create_all(engine)
|
| 26 |
+
MLInput.metadata.create_all(engine)
|
| 27 |
+
MLOutput.metadata.create_all(engine)
|
| 28 |
+
|
| 29 |
+
session = SQLSession()
|
| 30 |
+
|
| 31 |
+
def get_db_override():
|
| 32 |
+
return session
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
app.dependency_overrides[get_db] = get_db_override
|
| 36 |
+
|
| 37 |
+
client = TestClient(app, raise_server_exceptions=False)
|
| 38 |
+
|
| 39 |
+
created = datetime(2025, 9, 15, 10, 11, 3, 950802, tzinfo=timezone.utc)
|
| 40 |
+
session.add_all(
|
| 41 |
+
[
|
| 42 |
+
MLModel(
|
| 43 |
+
id=uuid.UUID("5b1c7b3a-0000-4000-8000-000000000001"),
|
| 44 |
+
name="baseline",
|
| 45 |
+
description="Baseline model",
|
| 46 |
+
created_at=created,
|
| 47 |
+
is_active=True,
|
| 48 |
+
),
|
| 49 |
+
MLModel(
|
| 50 |
+
id=uuid.UUID("5b1c7b3a-0000-4000-8000-000000000002"),
|
| 51 |
+
name="best_model",
|
| 52 |
+
description="XGB v1",
|
| 53 |
+
created_at=created,
|
| 54 |
+
is_active=True,
|
| 55 |
+
),
|
| 56 |
+
MLModel(
|
| 57 |
+
id=uuid.UUID("5b1c7b3a-0000-4000-8000-000000000003"),
|
| 58 |
+
name="logistic_regression",
|
| 59 |
+
description="Logistic Regression",
|
| 60 |
+
created_at=created,
|
| 61 |
+
is_active=True,
|
| 62 |
+
),
|
| 63 |
+
]
|
| 64 |
+
)
|
| 65 |
+
session.commit()
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
payload = {
|
| 69 |
+
"model_name": "best_model",
|
| 70 |
+
"inputs": [{
|
| 71 |
+
"id_employee": 123,
|
| 72 |
+
"age": 35,
|
| 73 |
+
"genre": "Homme",
|
| 74 |
+
"revenu_mensuel": 4200,
|
| 75 |
+
"statut_marital": "Célibataire",
|
| 76 |
+
"departement": "Ventes",
|
| 77 |
+
"poste": "Commercial",
|
| 78 |
+
"nombre_experiences_precedentes": 2,
|
| 79 |
+
"nombre_heures_travailless": 40,
|
| 80 |
+
"annee_experience_totale": 5,
|
| 81 |
+
"annees_dans_l_entreprise": 2,
|
| 82 |
+
"annees_dans_le_poste_actuel": 1,
|
| 83 |
+
"nombre_participation_pee": 1,
|
| 84 |
+
"nb_formations_suivies": 3,
|
| 85 |
+
"nombre_employee_sous_responsabilite": 0,
|
| 86 |
+
"code_sondage": 7,
|
| 87 |
+
"distance_domicile_travail": 12,
|
| 88 |
+
"niveau_education": 3,
|
| 89 |
+
"domaine_etude": "Marketing",
|
| 90 |
+
"ayant_enfants": "Non",
|
| 91 |
+
"frequence_deplacement": "Rarement",
|
| 92 |
+
"annees_depuis_la_derniere_promotion": 0,
|
| 93 |
+
"annes_sous_responsable_actuel": 1,
|
| 94 |
+
"satisfaction_employee_environnement": 3,
|
| 95 |
+
"note_evaluation_precedente": 4,
|
| 96 |
+
"niveau_hierarchique_poste": 2,
|
| 97 |
+
"satisfaction_employee_nature_travail": 3,
|
| 98 |
+
"satisfaction_employee_equipe": 4,
|
| 99 |
+
"satisfaction_employee_equilibre_pro_perso": 3,
|
| 100 |
+
"eval_number": "E2",
|
| 101 |
+
"note_evaluation_actuelle": 4,
|
| 102 |
+
"heure_supplementaires": "Non",
|
| 103 |
+
"augementation_salaire_precedente": 11
|
| 104 |
+
}]
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
resp = client.post("/predict", json=payload)
|
| 109 |
+
|
| 110 |
+
print("STATUS:", resp.status_code)
|
| 111 |
+
print("BODY:", resp.text)
|
| 112 |
+
|
| 113 |
+
app.dependency_overrides.clear()
|
| 114 |
+
session.close()
|
| 115 |
+
|
| 116 |
+
assert resp.status_code == 200
|
| 117 |
+
data = resp.json()
|
| 118 |
+
assert data["model_name"] == "best_model"
|
| 119 |
+
assert isinstance(data["results"], list)
|
| 120 |
+
assert len(data["results"]) == 1
|
| 121 |
+
|
| 122 |
+
result = data["results"][0]
|
| 123 |
+
assert result["label"] == "reste_dans_l_entreprise"
|
| 124 |
+
assert isinstance(result["proba"], float)
|
| 125 |
+
assert 0 <= result["proba"] <= 1
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def test_not_found_model(tmp_path):
|
| 129 |
+
db_path = tmp_path / "testing.db"
|
| 130 |
+
engine = create_engine(
|
| 131 |
+
f"sqlite:///{db_path}",
|
| 132 |
+
connect_args={"check_same_thread": False},
|
| 133 |
+
future=True,
|
| 134 |
+
)
|
| 135 |
+
SQLSession = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
| 136 |
+
|
| 137 |
+
MLModel.metadata.create_all(engine)
|
| 138 |
+
MLInput.metadata.create_all(engine)
|
| 139 |
+
MLOutput.metadata.create_all(engine)
|
| 140 |
+
|
| 141 |
+
session = SQLSession()
|
| 142 |
+
|
| 143 |
+
def get_db_override():
|
| 144 |
+
return session
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
app.dependency_overrides[get_db] = get_db_override
|
| 148 |
+
|
| 149 |
+
client = TestClient(app, raise_server_exceptions=False)
|
| 150 |
+
|
| 151 |
+
created = datetime(2025, 9, 15, 10, 11, 3, 950802, tzinfo=timezone.utc)
|
| 152 |
+
session.add_all(
|
| 153 |
+
[
|
| 154 |
+
MLModel(
|
| 155 |
+
id=uuid.UUID("5b1c7b3a-0000-4000-8000-000000000001"),
|
| 156 |
+
name="baseline",
|
| 157 |
+
description="Baseline model",
|
| 158 |
+
created_at=created,
|
| 159 |
+
is_active=True,
|
| 160 |
+
),
|
| 161 |
+
]
|
| 162 |
+
)
|
| 163 |
+
session.commit()
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
payload = {
|
| 167 |
+
"model_name": "best_model",
|
| 168 |
+
"inputs": [{
|
| 169 |
+
"id_employee": 123,
|
| 170 |
+
"age": 35,
|
| 171 |
+
"genre": "Homme",
|
| 172 |
+
"revenu_mensuel": 4200,
|
| 173 |
+
"statut_marital": "Célibataire",
|
| 174 |
+
"departement": "Ventes",
|
| 175 |
+
"poste": "Commercial",
|
| 176 |
+
"nombre_experiences_precedentes": 2,
|
| 177 |
+
"nombre_heures_travailless": 40,
|
| 178 |
+
"annee_experience_totale": 5,
|
| 179 |
+
"annees_dans_l_entreprise": 2,
|
| 180 |
+
"annees_dans_le_poste_actuel": 1,
|
| 181 |
+
"nombre_participation_pee": 1,
|
| 182 |
+
"nb_formations_suivies": 3,
|
| 183 |
+
"nombre_employee_sous_responsabilite": 0,
|
| 184 |
+
"code_sondage": 7,
|
| 185 |
+
"distance_domicile_travail": 12,
|
| 186 |
+
"niveau_education": 3,
|
| 187 |
+
"domaine_etude": "Marketing",
|
| 188 |
+
"ayant_enfants": "Non",
|
| 189 |
+
"frequence_deplacement": "Rarement",
|
| 190 |
+
"annees_depuis_la_derniere_promotion": 0,
|
| 191 |
+
"annes_sous_responsable_actuel": 1,
|
| 192 |
+
"satisfaction_employee_environnement": 3,
|
| 193 |
+
"note_evaluation_precedente": 4,
|
| 194 |
+
"niveau_hierarchique_poste": 2,
|
| 195 |
+
"satisfaction_employee_nature_travail": 3,
|
| 196 |
+
"satisfaction_employee_equipe": 4,
|
| 197 |
+
"satisfaction_employee_equilibre_pro_perso": 3,
|
| 198 |
+
"eval_number": "E2",
|
| 199 |
+
"note_evaluation_actuelle": 4,
|
| 200 |
+
"heure_supplementaires": "Non",
|
| 201 |
+
"augementation_salaire_precedente": 11
|
| 202 |
+
}]
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
resp = client.post("/predict", json=payload)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
app.dependency_overrides.clear()
|
| 210 |
+
session.close()
|
| 211 |
+
|
| 212 |
+
assert resp.status_code == 404
|
| 213 |
+
data = resp.json()
|
| 214 |
+
assert data["detail"] == "Modèle introuvable ou inactif"
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def test_inactif_model(tmp_path):
|
| 218 |
+
db_path = tmp_path / "testing.db"
|
| 219 |
+
engine = create_engine(
|
| 220 |
+
f"sqlite:///{db_path}",
|
| 221 |
+
connect_args={"check_same_thread": False},
|
| 222 |
+
future=True,
|
| 223 |
+
)
|
| 224 |
+
SQLSession = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
| 225 |
+
|
| 226 |
+
MLModel.metadata.create_all(engine)
|
| 227 |
+
MLInput.metadata.create_all(engine)
|
| 228 |
+
MLOutput.metadata.create_all(engine)
|
| 229 |
+
|
| 230 |
+
session = SQLSession()
|
| 231 |
+
|
| 232 |
+
def get_db_override():
|
| 233 |
+
return session
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
app.dependency_overrides[get_db] = get_db_override
|
| 237 |
+
|
| 238 |
+
client = TestClient(app, raise_server_exceptions=False)
|
| 239 |
+
|
| 240 |
+
created = datetime(2025, 9, 15, 10, 11, 3, 950802, tzinfo=timezone.utc)
|
| 241 |
+
session.add_all(
|
| 242 |
+
[
|
| 243 |
+
MLModel(
|
| 244 |
+
id=uuid.UUID("5b1c7b3a-0000-4000-8000-000000000001"),
|
| 245 |
+
name="baseline",
|
| 246 |
+
description="Baseline model",
|
| 247 |
+
created_at=created,
|
| 248 |
+
is_active=False,
|
| 249 |
+
),
|
| 250 |
+
]
|
| 251 |
+
)
|
| 252 |
+
session.commit()
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
payload = {
|
| 256 |
+
"model_name": "baseline",
|
| 257 |
+
"inputs": [{
|
| 258 |
+
"id_employee": 123,
|
| 259 |
+
"age": 35,
|
| 260 |
+
"genre": "Homme",
|
| 261 |
+
"revenu_mensuel": 4200,
|
| 262 |
+
"statut_marital": "Célibataire",
|
| 263 |
+
"departement": "Ventes",
|
| 264 |
+
"poste": "Commercial",
|
| 265 |
+
"nombre_experiences_precedentes": 2,
|
| 266 |
+
"nombre_heures_travailless": 40,
|
| 267 |
+
"annee_experience_totale": 5,
|
| 268 |
+
"annees_dans_l_entreprise": 2,
|
| 269 |
+
"annees_dans_le_poste_actuel": 1,
|
| 270 |
+
"nombre_participation_pee": 1,
|
| 271 |
+
"nb_formations_suivies": 3,
|
| 272 |
+
"nombre_employee_sous_responsabilite": 0,
|
| 273 |
+
"code_sondage": 7,
|
| 274 |
+
"distance_domicile_travail": 12,
|
| 275 |
+
"niveau_education": 3,
|
| 276 |
+
"domaine_etude": "Marketing",
|
| 277 |
+
"ayant_enfants": "Non",
|
| 278 |
+
"frequence_deplacement": "Rarement",
|
| 279 |
+
"annees_depuis_la_derniere_promotion": 0,
|
| 280 |
+
"annes_sous_responsable_actuel": 1,
|
| 281 |
+
"satisfaction_employee_environnement": 3,
|
| 282 |
+
"note_evaluation_precedente": 4,
|
| 283 |
+
"niveau_hierarchique_poste": 2,
|
| 284 |
+
"satisfaction_employee_nature_travail": 3,
|
| 285 |
+
"satisfaction_employee_equipe": 4,
|
| 286 |
+
"satisfaction_employee_equilibre_pro_perso": 3,
|
| 287 |
+
"eval_number": "E2",
|
| 288 |
+
"note_evaluation_actuelle": 4,
|
| 289 |
+
"heure_supplementaires": "Non",
|
| 290 |
+
"augementation_salaire_precedente": 11
|
| 291 |
+
}]
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
resp = client.post("/predict", json=payload)
|
| 296 |
+
|
| 297 |
+
print("STATUS:", resp.status_code)
|
| 298 |
+
print("BODY:", resp.text)
|
| 299 |
+
|
| 300 |
+
app.dependency_overrides.clear()
|
| 301 |
+
session.close()
|
| 302 |
+
|
| 303 |
+
assert resp.status_code == 404
|
| 304 |
+
data = resp.json()
|
| 305 |
+
assert data["detail"] == "Modèle introuvable ou inactif"
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def test_list_models_returns_500_when_db_fails():
|
| 309 |
+
class BrokenSession:
|
| 310 |
+
def query(self, *a, **kw):
|
| 311 |
+
raise RuntimeError("DB is down")
|
| 312 |
+
|
| 313 |
+
def get_db_override():
|
| 314 |
+
yield BrokenSession()
|
| 315 |
+
|
| 316 |
+
app.dependency_overrides[get_db] = get_db_override
|
| 317 |
+
client = TestClient(app, raise_server_exceptions=False)
|
| 318 |
+
|
| 319 |
+
payload = {
|
| 320 |
+
"model_name": "baseline",
|
| 321 |
+
"inputs": [{
|
| 322 |
+
"id_employee": 123,
|
| 323 |
+
"age": 35,
|
| 324 |
+
"genre": "Homme",
|
| 325 |
+
"revenu_mensuel": 4200,
|
| 326 |
+
"statut_marital": "Célibataire",
|
| 327 |
+
"departement": "Ventes",
|
| 328 |
+
"poste": "Commercial",
|
| 329 |
+
"nombre_experiences_precedentes": 2,
|
| 330 |
+
"nombre_heures_travailless": 40,
|
| 331 |
+
"annee_experience_totale": 5,
|
| 332 |
+
"annees_dans_l_entreprise": 2,
|
| 333 |
+
"annees_dans_le_poste_actuel": 1,
|
| 334 |
+
"nombre_participation_pee": 1,
|
| 335 |
+
"nb_formations_suivies": 3,
|
| 336 |
+
"nombre_employee_sous_responsabilite": 0,
|
| 337 |
+
"code_sondage": 7,
|
| 338 |
+
"distance_domicile_travail": 12,
|
| 339 |
+
"niveau_education": 3,
|
| 340 |
+
"domaine_etude": "Marketing",
|
| 341 |
+
"ayant_enfants": "Non",
|
| 342 |
+
"frequence_deplacement": "Rarement",
|
| 343 |
+
"annees_depuis_la_derniere_promotion": 0,
|
| 344 |
+
"annes_sous_responsable_actuel": 1,
|
| 345 |
+
"satisfaction_employee_environnement": 3,
|
| 346 |
+
"note_evaluation_precedente": 4,
|
| 347 |
+
"niveau_hierarchique_poste": 2,
|
| 348 |
+
"satisfaction_employee_nature_travail": 3,
|
| 349 |
+
"satisfaction_employee_equipe": 4,
|
| 350 |
+
"satisfaction_employee_equilibre_pro_perso": 3,
|
| 351 |
+
"eval_number": "E2",
|
| 352 |
+
"note_evaluation_actuelle": 4,
|
| 353 |
+
"heure_supplementaires": "Non",
|
| 354 |
+
"augementation_salaire_precedente": 11
|
| 355 |
+
}]
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
resp = client.post("/predict", json=payload)
|
| 360 |
+
|
| 361 |
+
app.dependency_overrides.clear()
|
| 362 |
+
|
| 363 |
+
assert resp.status_code == 500
|
| 364 |
+
|
tests/unit/test_features.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import pytest
|
| 3 |
+
from features import compute_features
|
| 4 |
+
|
| 5 |
+
def test_compute_features_returns_matrix():
|
| 6 |
+
df = pd.DataFrame([{"age": 35, "genre": "Homme", "revenu_mensuel": 4200,
|
| 7 |
+
"satisfaction_employee_environnement": 3,
|
| 8 |
+
"satisfaction_employee_nature_travail": 3,
|
| 9 |
+
"satisfaction_employee_equipe": 3,
|
| 10 |
+
"satisfaction_employee_equilibre_pro_perso": 3, "note_evaluation_actuelle": 2, 'note_evaluation_precedente' : 3, "annes_sous_responsable_actuel" : 2, "annees_dans_le_poste_actuel" : 4, "niveau_hierarchique_poste": 2, "distance_domicile_travail" : 5}])
|
| 11 |
+
X = compute_features(df)
|
| 12 |
+
assert hasattr(X, "shape")
|
| 13 |
+
assert X.shape[0] == 1
|
| 14 |
+
|
| 15 |
+
def test_compute_features_raises_on_empty():
|
| 16 |
+
with pytest.raises(Exception):
|
| 17 |
+
compute_features(pd.DataFrame())
|