marintosti12 commited on
Commit
d0fda5a
·
1 Parent(s): a7e3b78

setup api

Browse files
.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 = "c08e7c5ec4a113a5743f5cc2f688ae67f4254f5951f50f37d80606c3c3efd9f3"
 
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())