Spaces:
Sleeping
Sleeping
Commit ·
179978e
0
Parent(s):
Initial commit
Browse files- .github/workflows/CI.yml +21 -0
- .gitignore +207 -0
- Dockerfile +40 -0
- LICENSE +21 -0
- Makefile +16 -0
- README.md +4 -0
- api/__init__.py +0 -0
- api/api.py +117 -0
- cli/__init__.py +0 -0
- cli/cli.py +82 -0
- mylib/__init__.py +3 -0
- mylib/classifier.py +128 -0
- pyproject.toml +24 -0
- templates/home.html +10 -0
- tests/sample.jpg +0 -0
- tests/test_api.py +106 -0
- tests/test_cli.py +60 -0
- tests/test_logic.py +73 -0
- uv.lock +0 -0
.github/workflows/CI.yml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI
|
| 2 |
+
on:
|
| 3 |
+
push:
|
| 4 |
+
branches: [ "main" ]
|
| 5 |
+
pull_request:
|
| 6 |
+
branches: [ "main" ]
|
| 7 |
+
workflow_dispatch:
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
build:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
steps:
|
| 13 |
+
- uses: actions/checkout@v3
|
| 14 |
+
- name: install packages
|
| 15 |
+
run: make install
|
| 16 |
+
- name: format
|
| 17 |
+
run: make format
|
| 18 |
+
- name: lint
|
| 19 |
+
run: make lint
|
| 20 |
+
- name: test
|
| 21 |
+
run: make test
|
.gitignore
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[codz]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# C extensions
|
| 7 |
+
*.so
|
| 8 |
+
|
| 9 |
+
# Distribution / packaging
|
| 10 |
+
.Python
|
| 11 |
+
build/
|
| 12 |
+
develop-eggs/
|
| 13 |
+
dist/
|
| 14 |
+
downloads/
|
| 15 |
+
eggs/
|
| 16 |
+
.eggs/
|
| 17 |
+
lib/
|
| 18 |
+
lib64/
|
| 19 |
+
parts/
|
| 20 |
+
sdist/
|
| 21 |
+
var/
|
| 22 |
+
wheels/
|
| 23 |
+
share/python-wheels/
|
| 24 |
+
*.egg-info/
|
| 25 |
+
.installed.cfg
|
| 26 |
+
*.egg
|
| 27 |
+
MANIFEST
|
| 28 |
+
|
| 29 |
+
# PyInstaller
|
| 30 |
+
# Usually these files are written by a python script from a template
|
| 31 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 32 |
+
*.manifest
|
| 33 |
+
*.spec
|
| 34 |
+
|
| 35 |
+
# Installer logs
|
| 36 |
+
pip-log.txt
|
| 37 |
+
pip-delete-this-directory.txt
|
| 38 |
+
|
| 39 |
+
# Unit test / coverage reports
|
| 40 |
+
htmlcov/
|
| 41 |
+
.tox/
|
| 42 |
+
.nox/
|
| 43 |
+
.coverage
|
| 44 |
+
.coverage.*
|
| 45 |
+
.cache
|
| 46 |
+
nosetests.xml
|
| 47 |
+
coverage.xml
|
| 48 |
+
*.cover
|
| 49 |
+
*.py.cover
|
| 50 |
+
.hypothesis/
|
| 51 |
+
.pytest_cache/
|
| 52 |
+
cover/
|
| 53 |
+
|
| 54 |
+
# Translations
|
| 55 |
+
*.mo
|
| 56 |
+
*.pot
|
| 57 |
+
|
| 58 |
+
# Django stuff:
|
| 59 |
+
*.log
|
| 60 |
+
local_settings.py
|
| 61 |
+
db.sqlite3
|
| 62 |
+
db.sqlite3-journal
|
| 63 |
+
|
| 64 |
+
# Flask stuff:
|
| 65 |
+
instance/
|
| 66 |
+
.webassets-cache
|
| 67 |
+
|
| 68 |
+
# Scrapy stuff:
|
| 69 |
+
.scrapy
|
| 70 |
+
|
| 71 |
+
# Sphinx documentation
|
| 72 |
+
docs/_build/
|
| 73 |
+
|
| 74 |
+
# PyBuilder
|
| 75 |
+
.pybuilder/
|
| 76 |
+
target/
|
| 77 |
+
|
| 78 |
+
# Jupyter Notebook
|
| 79 |
+
.ipynb_checkpoints
|
| 80 |
+
|
| 81 |
+
# IPython
|
| 82 |
+
profile_default/
|
| 83 |
+
ipython_config.py
|
| 84 |
+
|
| 85 |
+
# pyenv
|
| 86 |
+
# For a library or package, you might want to ignore these files since the code is
|
| 87 |
+
# intended to run in multiple environments; otherwise, check them in:
|
| 88 |
+
# .python-version
|
| 89 |
+
|
| 90 |
+
# pipenv
|
| 91 |
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
| 92 |
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
| 93 |
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
| 94 |
+
# install all needed dependencies.
|
| 95 |
+
#Pipfile.lock
|
| 96 |
+
|
| 97 |
+
# UV
|
| 98 |
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
| 99 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 100 |
+
# commonly ignored for libraries.
|
| 101 |
+
#uv.lock
|
| 102 |
+
|
| 103 |
+
# poetry
|
| 104 |
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
| 105 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 106 |
+
# commonly ignored for libraries.
|
| 107 |
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
| 108 |
+
#poetry.lock
|
| 109 |
+
#poetry.toml
|
| 110 |
+
|
| 111 |
+
# pdm
|
| 112 |
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
| 113 |
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
| 114 |
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
| 115 |
+
#pdm.lock
|
| 116 |
+
#pdm.toml
|
| 117 |
+
.pdm-python
|
| 118 |
+
.pdm-build/
|
| 119 |
+
|
| 120 |
+
# pixi
|
| 121 |
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
| 122 |
+
#pixi.lock
|
| 123 |
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
| 124 |
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
| 125 |
+
.pixi
|
| 126 |
+
|
| 127 |
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
| 128 |
+
__pypackages__/
|
| 129 |
+
|
| 130 |
+
# Celery stuff
|
| 131 |
+
celerybeat-schedule
|
| 132 |
+
celerybeat.pid
|
| 133 |
+
|
| 134 |
+
# SageMath parsed files
|
| 135 |
+
*.sage.py
|
| 136 |
+
|
| 137 |
+
# Environments
|
| 138 |
+
.env
|
| 139 |
+
.envrc
|
| 140 |
+
.venv
|
| 141 |
+
env/
|
| 142 |
+
venv/
|
| 143 |
+
ENV/
|
| 144 |
+
env.bak/
|
| 145 |
+
venv.bak/
|
| 146 |
+
|
| 147 |
+
# Spyder project settings
|
| 148 |
+
.spyderproject
|
| 149 |
+
.spyproject
|
| 150 |
+
|
| 151 |
+
# Rope project settings
|
| 152 |
+
.ropeproject
|
| 153 |
+
|
| 154 |
+
# mkdocs documentation
|
| 155 |
+
/site
|
| 156 |
+
|
| 157 |
+
# mypy
|
| 158 |
+
.mypy_cache/
|
| 159 |
+
.dmypy.json
|
| 160 |
+
dmypy.json
|
| 161 |
+
|
| 162 |
+
# Pyre type checker
|
| 163 |
+
.pyre/
|
| 164 |
+
|
| 165 |
+
# pytype static type analyzer
|
| 166 |
+
.pytype/
|
| 167 |
+
|
| 168 |
+
# Cython debug symbols
|
| 169 |
+
cython_debug/
|
| 170 |
+
|
| 171 |
+
# PyCharm
|
| 172 |
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
| 173 |
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
| 174 |
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
| 175 |
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
| 176 |
+
#.idea/
|
| 177 |
+
|
| 178 |
+
# Abstra
|
| 179 |
+
# Abstra is an AI-powered process automation framework.
|
| 180 |
+
# Ignore directories containing user credentials, local state, and settings.
|
| 181 |
+
# Learn more at https://abstra.io/docs
|
| 182 |
+
.abstra/
|
| 183 |
+
|
| 184 |
+
# Visual Studio Code
|
| 185 |
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
| 186 |
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
| 187 |
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
| 188 |
+
# you could uncomment the following to ignore the entire vscode folder
|
| 189 |
+
# .vscode/
|
| 190 |
+
|
| 191 |
+
# Ruff stuff:
|
| 192 |
+
.ruff_cache/
|
| 193 |
+
|
| 194 |
+
# PyPI configuration file
|
| 195 |
+
.pypirc
|
| 196 |
+
|
| 197 |
+
# Cursor
|
| 198 |
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
| 199 |
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
| 200 |
+
# refer to https://docs.cursor.com/context/ignore-files
|
| 201 |
+
.cursorignore
|
| 202 |
+
.cursorindexingignore
|
| 203 |
+
|
| 204 |
+
# Marimo
|
| 205 |
+
marimo/_static/
|
| 206 |
+
marimo/_lsp/
|
| 207 |
+
__marimo__/
|
Dockerfile
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Base image with Python 3.13
|
| 2 |
+
FROM python:3.13-slim AS base
|
| 3 |
+
|
| 4 |
+
# Recommended environment variables
|
| 5 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 6 |
+
ENV PYTHONUNBUFFERED=1
|
| 7 |
+
ENV UV_SYSTEM_PYTHON=1
|
| 8 |
+
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
# Intall the requiered dependencies of the system
|
| 12 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 13 |
+
build-essential \
|
| 14 |
+
libjpeg-dev \
|
| 15 |
+
zlib1g-dev \
|
| 16 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
+
|
| 18 |
+
# Install uv and the dependencies of the project
|
| 19 |
+
FROM base AS builder
|
| 20 |
+
# Install uv
|
| 21 |
+
RUN pip install --no-cache-dir uv
|
| 22 |
+
# Copy the dependencies file
|
| 23 |
+
COPY pyproject.toml .
|
| 24 |
+
# Copy the lock file if exists
|
| 25 |
+
COPY uv.lock* .
|
| 26 |
+
# Install the dependencies of the project in the system's environment
|
| 27 |
+
RUN uv pip install --system --no-cache .
|
| 28 |
+
|
| 29 |
+
# Copy the source code and prepare the execution environment
|
| 30 |
+
FROM base AS runtime
|
| 31 |
+
# Copy the installed dependencies
|
| 32 |
+
COPY --from=builder /usr/local /usr/local
|
| 33 |
+
# Copy the source code of the API, logic and home.html
|
| 34 |
+
COPY api ./api
|
| 35 |
+
COPY mylib ./mylib
|
| 36 |
+
COPY templates ./templates
|
| 37 |
+
# Expose the port associated with the API created with FastAPI
|
| 38 |
+
EXPOSE 8000
|
| 39 |
+
# Default command: it starts the API with uvicorn
|
| 40 |
+
CMD ["uvicorn", "api.api:app", "--host", "0.0.0.0", "--port", "8000"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 Iker-Jauregui
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
Makefile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
install:
|
| 2 |
+
pip install uv &&\
|
| 3 |
+
uv sync
|
| 4 |
+
|
| 5 |
+
test:
|
| 6 |
+
uv run python -m pytest tests/ -vv --cov=mylib --cov=api --cov=cli
|
| 7 |
+
|
| 8 |
+
format:
|
| 9 |
+
uv run black mylib/*.py cli/*.py api/*.py #*.py
|
| 10 |
+
|
| 11 |
+
lint:
|
| 12 |
+
uv run pylint --disable=R,C --ignore-patterns=test_.*\.py mylib/*.py cli/*.py api/*.py
|
| 13 |
+
|
| 14 |
+
refactor: format lint
|
| 15 |
+
|
| 16 |
+
all: install format lint test
|
README.md
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[](https://github.com/Iker-Jauregui/MLOps-Lab2/actions/workflows/CI.yml)
|
| 2 |
+
|
| 3 |
+
# MLOps-Lab2
|
| 4 |
+
Repo for Lab2 of MLOps subject
|
api/__init__.py
ADDED
|
File without changes
|
api/api.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application for image classification."""
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
import uvicorn
|
| 5 |
+
from PIL import Image
|
| 6 |
+
from fastapi import FastAPI, Form, HTTPException, UploadFile, File
|
| 7 |
+
from fastapi.templating import Jinja2Templates
|
| 8 |
+
from fastapi.requests import Request
|
| 9 |
+
from fastapi.responses import HTMLResponse
|
| 10 |
+
|
| 11 |
+
from mylib.classifier import predict as predict_func
|
| 12 |
+
from mylib.classifier import resize as resize_func
|
| 13 |
+
|
| 14 |
+
# Create an instance of FastAPI
|
| 15 |
+
app = FastAPI(
|
| 16 |
+
title="API of the Image Classifier using FastAPI",
|
| 17 |
+
description="API to perform image predictions and transforms using mylib.classifier",
|
| 18 |
+
version="1.0.0",
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
# We use the templates folder to obtain HTML files
|
| 22 |
+
templates = Jinja2Templates(directory="templates")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# Initial endpoint
|
| 26 |
+
@app.get("/", response_class=HTMLResponse)
|
| 27 |
+
def home(request: Request):
|
| 28 |
+
"""Home endpoint returning HTML template."""
|
| 29 |
+
return templates.TemplateResponse(request=request, name="home.html")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# Main endpoint to perform the image prediction
|
| 33 |
+
@app.post("/predict")
|
| 34 |
+
async def predict_endpoint(
|
| 35 |
+
file: UploadFile = File(...),
|
| 36 |
+
class_names: str = Form(default="cardboard,paper,plastic,metal,trash,glass"),
|
| 37 |
+
):
|
| 38 |
+
"""
|
| 39 |
+
Predict the class of the input image.
|
| 40 |
+
|
| 41 |
+
Parameters
|
| 42 |
+
----------
|
| 43 |
+
file : UploadFile
|
| 44 |
+
Image file to classify
|
| 45 |
+
class_names : str
|
| 46 |
+
Comma-separated class names (default: "cardboard,paper,plastic,metal,trash,glass")
|
| 47 |
+
|
| 48 |
+
Returns
|
| 49 |
+
-------
|
| 50 |
+
dict
|
| 51 |
+
Dictionary with predicted class
|
| 52 |
+
"""
|
| 53 |
+
try:
|
| 54 |
+
# Read image from upload
|
| 55 |
+
contents = await file.read()
|
| 56 |
+
image = Image.open(io.BytesIO(contents))
|
| 57 |
+
|
| 58 |
+
# Convert class_names string to list
|
| 59 |
+
class_list = [c.strip() for c in class_names.split(",")]
|
| 60 |
+
|
| 61 |
+
# Get prediction
|
| 62 |
+
prediction = predict_func(image, class_list)
|
| 63 |
+
|
| 64 |
+
return {"predicted_class": prediction}
|
| 65 |
+
|
| 66 |
+
except (FileNotFoundError, IOError, ValueError) as e:
|
| 67 |
+
raise HTTPException(
|
| 68 |
+
status_code=400, detail=f"Error processing image: {str(e)}"
|
| 69 |
+
) from e
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# Main endpoint to perform the image resize
|
| 73 |
+
@app.post("/resize")
|
| 74 |
+
async def resize_endpoint(
|
| 75 |
+
file: UploadFile = File(...), width: int = Form(...), height: int = Form(...)
|
| 76 |
+
):
|
| 77 |
+
"""
|
| 78 |
+
Resize the input image.
|
| 79 |
+
|
| 80 |
+
Parameters
|
| 81 |
+
----------
|
| 82 |
+
file : UploadFile
|
| 83 |
+
Image file to resize
|
| 84 |
+
width : int
|
| 85 |
+
Target width (must be positive)
|
| 86 |
+
height : int
|
| 87 |
+
Target height (must be positive)
|
| 88 |
+
|
| 89 |
+
Returns
|
| 90 |
+
-------
|
| 91 |
+
dict
|
| 92 |
+
Dictionary with new image dimensions
|
| 93 |
+
"""
|
| 94 |
+
if width <= 0:
|
| 95 |
+
raise HTTPException(status_code=400, detail="'width' must be a positive value")
|
| 96 |
+
if height <= 0:
|
| 97 |
+
raise HTTPException(status_code=400, detail="'height' must be a positive value")
|
| 98 |
+
|
| 99 |
+
try:
|
| 100 |
+
# Read image from upload
|
| 101 |
+
contents = await file.read()
|
| 102 |
+
image = Image.open(io.BytesIO(contents))
|
| 103 |
+
|
| 104 |
+
# Resize image
|
| 105 |
+
new_size = resize_func(image, width, height)
|
| 106 |
+
|
| 107 |
+
return {"resized_dimensions": new_size}
|
| 108 |
+
|
| 109 |
+
except (FileNotFoundError, IOError, ValueError) as e:
|
| 110 |
+
raise HTTPException(
|
| 111 |
+
status_code=400, detail=f"Error resizing image: {str(e)}"
|
| 112 |
+
) from e
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# Entry point (for direct execution only)
|
| 116 |
+
if __name__ == "__main__":
|
| 117 |
+
uvicorn.run("api.api:app", host="0.0.0.0", port=8000, reload=True)
|
cli/__init__.py
ADDED
|
File without changes
|
cli/cli.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Main CLI or app entry point
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import click
|
| 6 |
+
from mylib.classifier import predict, resize
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
# We create a group of commands
|
| 10 |
+
@click.group()
|
| 11 |
+
def cli():
|
| 12 |
+
"""Main CLI to perform image operations."""
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ============================================================================
|
| 16 |
+
# INFERENCE GROUP - Image inference operations
|
| 17 |
+
# ============================================================================
|
| 18 |
+
@cli.group()
|
| 19 |
+
def inference():
|
| 20 |
+
"""Commands for image inference operations."""
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# We create a command, named predict, associated with the previous group
|
| 24 |
+
@inference.command("predict")
|
| 25 |
+
@click.argument("image-path", type=str)
|
| 26 |
+
@click.option(
|
| 27 |
+
"--class-names",
|
| 28 |
+
default="cardboard,paper,plastic,metal,trash,glass",
|
| 29 |
+
type=str,
|
| 30 |
+
help="Comma-separated class names (e.g., 'cat,dog,bird').",
|
| 31 |
+
)
|
| 32 |
+
def predict_cli(image_path, class_names):
|
| 33 |
+
"""Predict image class.
|
| 34 |
+
|
| 35 |
+
Example:
|
| 36 |
+
uv run python -m cli.cli inference predict 'sample.jpg'
|
| 37 |
+
"""
|
| 38 |
+
try:
|
| 39 |
+
# Convert comma-separated string to list
|
| 40 |
+
class_list = [c.strip() for c in class_names.split(",")]
|
| 41 |
+
result = predict(image_path, class_list)
|
| 42 |
+
click.echo(click.style(f"Predicted class: {result}", fg="green"))
|
| 43 |
+
except (FileNotFoundError, IOError, ValueError) as e:
|
| 44 |
+
click.echo(click.style(f"Error: {str(e)}", fg="red"), err=True)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ============================================================================
|
| 48 |
+
# TRANSFORM GROUP - Image transform operations
|
| 49 |
+
# ============================================================================
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@cli.group()
|
| 53 |
+
def transform():
|
| 54 |
+
"""Commands for image transform operations."""
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@transform.command("resize")
|
| 58 |
+
@click.argument("image-path", type=str)
|
| 59 |
+
@click.argument("width", type=int)
|
| 60 |
+
@click.argument("height", type=int)
|
| 61 |
+
def resize_cli(image_path, width, height):
|
| 62 |
+
"""Resize image.
|
| 63 |
+
|
| 64 |
+
Example:
|
| 65 |
+
uv run python -m cli.cli transform resize 'sample.jpg' 224 224
|
| 66 |
+
"""
|
| 67 |
+
try:
|
| 68 |
+
if width <= 0:
|
| 69 |
+
raise ValueError("'width' must be a positive value")
|
| 70 |
+
if height <= 0:
|
| 71 |
+
raise ValueError("'height' must be a positive value")
|
| 72 |
+
|
| 73 |
+
result = resize(image_path, width, height)
|
| 74 |
+
click.echo(click.style(f"Resized to: {result}", fg="green"))
|
| 75 |
+
except (FileNotFoundError, IOError, ValueError) as e:
|
| 76 |
+
click.echo(click.style(f"Error: {str(e)}", fg="red"), err=True)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# Main entry point
|
| 80 |
+
if __name__ == "__main__":
|
| 81 |
+
# pylint: disable=no-value-for-parameter
|
| 82 |
+
cli()
|
mylib/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""mlops_practice package."""
|
| 2 |
+
|
| 3 |
+
__version__ = "0.1.0"
|
mylib/classifier.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Image classifier library
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import random
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from PIL import Image
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def predict(image_path, class_names=None):
|
| 11 |
+
"""
|
| 12 |
+
Predict the class of an image.
|
| 13 |
+
|
| 14 |
+
Loads image from file or PIL Image object and returns predicted class.
|
| 15 |
+
|
| 16 |
+
Parameters
|
| 17 |
+
----------
|
| 18 |
+
image_path : str, Path, or PIL.Image
|
| 19 |
+
Path to image file (str or Path object) or PIL Image object directly.
|
| 20 |
+
Supported formats: JPG, PNG, BMP, GIF, TIFF.
|
| 21 |
+
class_names : list of str, optional
|
| 22 |
+
List of class names. Default: ['cardboard', 'paper', 'plastic', 'metal', 'trash', 'glass']
|
| 23 |
+
|
| 24 |
+
Returns
|
| 25 |
+
-------
|
| 26 |
+
str
|
| 27 |
+
Predicted class name (randomly selected from class_names).
|
| 28 |
+
|
| 29 |
+
Raises
|
| 30 |
+
------
|
| 31 |
+
FileNotFoundError
|
| 32 |
+
If image file path does not exist.
|
| 33 |
+
IOError
|
| 34 |
+
If image file cannot be read.
|
| 35 |
+
ValueError
|
| 36 |
+
If image format is not supported or class_names is empty.
|
| 37 |
+
|
| 38 |
+
Examples
|
| 39 |
+
--------
|
| 40 |
+
>>> predicted_class = predict("sample.jpg", ['cat', 'dog'])
|
| 41 |
+
"""
|
| 42 |
+
if class_names is None:
|
| 43 |
+
class_names = ["cardboard", "paper", "plastic", "metal", "trash", "glass"]
|
| 44 |
+
|
| 45 |
+
if not class_names:
|
| 46 |
+
raise ValueError("class_names cannot be empty")
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
# Handle both file paths and PIL Images
|
| 50 |
+
if isinstance(image_path, (str, Path)):
|
| 51 |
+
if not Path(image_path).exists():
|
| 52 |
+
raise FileNotFoundError(f"Image file not found: {image_path}")
|
| 53 |
+
Image.open(image_path).convert("RGB")
|
| 54 |
+
elif isinstance(image_path, Image.Image):
|
| 55 |
+
image_path.convert("RGB")
|
| 56 |
+
else:
|
| 57 |
+
raise ValueError(f"Unsupported image_path type: {type(image_path)}")
|
| 58 |
+
|
| 59 |
+
# For Lab1: randomly select a class
|
| 60 |
+
predicted_class = random.choice(class_names)
|
| 61 |
+
return predicted_class
|
| 62 |
+
|
| 63 |
+
except FileNotFoundError:
|
| 64 |
+
raise
|
| 65 |
+
except Exception as e:
|
| 66 |
+
raise IOError(f"Error loading image: {str(e)}") from e
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def resize(image_path, width, height):
|
| 70 |
+
"""
|
| 71 |
+
Resize an image to specified dimensions.
|
| 72 |
+
|
| 73 |
+
Parameters
|
| 74 |
+
----------
|
| 75 |
+
image_path : str, Path, or PIL.Image
|
| 76 |
+
Path to image file or PIL Image object.
|
| 77 |
+
width : int
|
| 78 |
+
Target width in pixels. Must be positive.
|
| 79 |
+
height : int
|
| 80 |
+
Target height in pixels. Must be positive.
|
| 81 |
+
|
| 82 |
+
Returns
|
| 83 |
+
-------
|
| 84 |
+
tuple of (int, int)
|
| 85 |
+
New dimensions (width, height) of the resized image.
|
| 86 |
+
|
| 87 |
+
Raises
|
| 88 |
+
------
|
| 89 |
+
FileNotFoundError
|
| 90 |
+
If image file path does not exist.
|
| 91 |
+
ValueError
|
| 92 |
+
If width or height are not positive integers.
|
| 93 |
+
IOError
|
| 94 |
+
If image file cannot be read.
|
| 95 |
+
|
| 96 |
+
Examples
|
| 97 |
+
--------
|
| 98 |
+
>>> new_size = resize("sample.jpg", 224, 224)
|
| 99 |
+
>>> print(new_size)
|
| 100 |
+
(224, 224)
|
| 101 |
+
"""
|
| 102 |
+
if width <= 0:
|
| 103 |
+
raise ValueError("'width' must be a positive integer")
|
| 104 |
+
if height <= 0:
|
| 105 |
+
raise ValueError("'height' must be a positive integer")
|
| 106 |
+
|
| 107 |
+
try:
|
| 108 |
+
# Handle both file paths and PIL Images
|
| 109 |
+
if isinstance(image_path, (str, Path)):
|
| 110 |
+
if not Path(image_path).exists():
|
| 111 |
+
raise FileNotFoundError(f"Image file not found: {image_path}")
|
| 112 |
+
image = Image.open(image_path).convert("RGB")
|
| 113 |
+
elif isinstance(image_path, Image.Image):
|
| 114 |
+
image = image_path.convert("RGB")
|
| 115 |
+
else:
|
| 116 |
+
raise ValueError(f"Unsupported image_path type: {type(image_path)}")
|
| 117 |
+
|
| 118 |
+
# Resize the image
|
| 119 |
+
resized_image = image.resize((width, height))
|
| 120 |
+
|
| 121 |
+
return resized_image.size # Returns (width, height)
|
| 122 |
+
|
| 123 |
+
except FileNotFoundError:
|
| 124 |
+
raise
|
| 125 |
+
except ValueError:
|
| 126 |
+
raise
|
| 127 |
+
except Exception as e:
|
| 128 |
+
raise IOError(f"Error resizing image: {str(e)}") from e
|
pyproject.toml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "mlops-lab1"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Add your description here"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.12"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"click>=8.3.0",
|
| 9 |
+
"fastapi>=0.121.2",
|
| 10 |
+
"httpx>=0.28.1",
|
| 11 |
+
"jinja2>=3.1.6",
|
| 12 |
+
"numpy>=2.3.4",
|
| 13 |
+
"pillow>=12.0.0",
|
| 14 |
+
"python-multipart>=0.0.20",
|
| 15 |
+
"uvicorn>=0.38.0",
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
[dependency-groups]
|
| 19 |
+
dev = [
|
| 20 |
+
"black>=25.11.0",
|
| 21 |
+
"pylint>=4.0.3",
|
| 22 |
+
"pytest>=9.0.1",
|
| 23 |
+
"pytest-cov>=7.0.0",
|
| 24 |
+
]
|
templates/home.html
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<title>Image Classifier API</title>
|
| 5 |
+
</head>
|
| 6 |
+
<body>
|
| 7 |
+
<h1>Welcome to the API of the image classifier</h1>
|
| 8 |
+
<p>Add <code>/docs</code> to the URL to test the endpoints of the application.</p>
|
| 9 |
+
</body>
|
| 10 |
+
</html>
|
tests/sample.jpg
ADDED
|
tests/test_api.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Integration testing with the API
|
| 3 |
+
"""
|
| 4 |
+
import io
|
| 5 |
+
import pytest
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from PIL import Image
|
| 8 |
+
from fastapi.testclient import TestClient
|
| 9 |
+
from api.api import app
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@pytest.fixture
|
| 13 |
+
def client():
|
| 14 |
+
"""Testing client from FastAPI."""
|
| 15 |
+
return TestClient(app)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@pytest.fixture
|
| 19 |
+
def sample_image_bytes():
|
| 20 |
+
"""Create a sample image in memory for testing."""
|
| 21 |
+
img = Image.new('RGB', (100, 100), color='red')
|
| 22 |
+
img_bytes = io.BytesIO()
|
| 23 |
+
img.save(img_bytes, format='JPEG')
|
| 24 |
+
img_bytes.seek(0)
|
| 25 |
+
return img_bytes
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_home_endpoint(client):
|
| 29 |
+
"""Verify that the endpoint / returns the right message."""
|
| 30 |
+
response = client.get("/")
|
| 31 |
+
assert response.status_code == 200
|
| 32 |
+
assert "text/html" in response.headers["content-type"]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_predict(client, sample_image_bytes):
|
| 36 |
+
"""Verify that the endpoint /predict performs the class prediction correctly."""
|
| 37 |
+
response = client.post(
|
| 38 |
+
"/predict",
|
| 39 |
+
files={"file": ("test.jpg", sample_image_bytes, "image/jpeg")},
|
| 40 |
+
data={"class_names": "cat,dog,bird"}
|
| 41 |
+
)
|
| 42 |
+
assert response.status_code == 200
|
| 43 |
+
data = response.json()
|
| 44 |
+
assert "predicted_class" in data
|
| 45 |
+
assert data["predicted_class"] in ["cat", "dog", "bird"]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_predict_invalid_file(client):
|
| 49 |
+
"""Verify that the endpoint /predict manages correctly invalid files."""
|
| 50 |
+
response = client.post(
|
| 51 |
+
"/predict",
|
| 52 |
+
files={"file": ("test.txt", b"not an image", "text/plain")}
|
| 53 |
+
)
|
| 54 |
+
assert response.status_code == 400
|
| 55 |
+
data = response.json()
|
| 56 |
+
assert "detail" in data
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_resize(client, sample_image_bytes):
|
| 60 |
+
"""Verify that the endpoint /resize performs the image resize correctly."""
|
| 61 |
+
response = client.post(
|
| 62 |
+
"/resize",
|
| 63 |
+
files={"file": ("test.jpg", sample_image_bytes, "image/jpeg")},
|
| 64 |
+
data={"width": "32", "height": "32"}
|
| 65 |
+
)
|
| 66 |
+
assert response.status_code == 200
|
| 67 |
+
data = response.json()
|
| 68 |
+
assert "resized_dimensions" in data
|
| 69 |
+
assert data["resized_dimensions"] == [32, 32]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_resize_invalid_width(client, sample_image_bytes):
|
| 73 |
+
"""Verify that the endpoint /resize manages correctly invalid widths."""
|
| 74 |
+
response = client.post(
|
| 75 |
+
"/resize",
|
| 76 |
+
files={"file": ("test.jpg", sample_image_bytes, "image/jpeg")},
|
| 77 |
+
data={"width": "0", "height": "32"}
|
| 78 |
+
)
|
| 79 |
+
assert response.status_code == 400
|
| 80 |
+
data = response.json()
|
| 81 |
+
assert "detail" in data
|
| 82 |
+
assert "'width' must be a positive value" in data["detail"]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_resize_invalid_height(client, sample_image_bytes):
|
| 86 |
+
"""Verify that the endpoint /resize manages correctly invalid heights."""
|
| 87 |
+
response = client.post(
|
| 88 |
+
"/resize",
|
| 89 |
+
files={"file": ("test.jpg", sample_image_bytes, "image/jpeg")},
|
| 90 |
+
data={"width": "32", "height": "0"}
|
| 91 |
+
)
|
| 92 |
+
assert response.status_code == 400
|
| 93 |
+
data = response.json()
|
| 94 |
+
assert "detail" in data
|
| 95 |
+
assert "'height' must be a positive value" in data["detail"]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_resize_invalid_parameters(client):
|
| 99 |
+
"""Verify that the endpoint /resize manages correctly missing parameters."""
|
| 100 |
+
response = client.post(
|
| 101 |
+
"/resize",
|
| 102 |
+
data={"width": "32", "height": "32"}
|
| 103 |
+
)
|
| 104 |
+
assert response.status_code == 422 # FastAPI returns 422 for validation errors
|
| 105 |
+
data = response.json()
|
| 106 |
+
assert "detail" in data
|
tests/test_cli.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Integration testing with the CLI
|
| 3 |
+
"""
|
| 4 |
+
import pytest
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from click.testing import CliRunner
|
| 7 |
+
from cli.cli import cli
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
# Fixture
|
| 11 |
+
@pytest.fixture
|
| 12 |
+
def runner():
|
| 13 |
+
"""Fixture to provide a CliRunner instance for all CLI tests."""
|
| 14 |
+
return CliRunner()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@pytest.fixture
|
| 18 |
+
def sample_image_path():
|
| 19 |
+
"""Provide path to test image."""
|
| 20 |
+
return str(Path(__file__).parent / "sample.jpg")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_help(runner):
|
| 24 |
+
"""Tests the command-line interface help message."""
|
| 25 |
+
result = runner.invoke(cli, ["--help"])
|
| 26 |
+
assert result.exit_code == 0
|
| 27 |
+
assert "Show this message and exit." in result.output
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# Testing of the predict_cli of the inference group
|
| 31 |
+
def test_predict_cli(runner, sample_image_path):
|
| 32 |
+
"""Tests the command-line interface predict command."""
|
| 33 |
+
result = runner.invoke(cli, ["inference", "predict", sample_image_path])
|
| 34 |
+
assert result.exit_code == 0
|
| 35 |
+
assert "Predicted class:" in result.output
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_predict_cli_with_custom_classes(runner, sample_image_path):
|
| 39 |
+
"""Tests predict with custom class names."""
|
| 40 |
+
result = runner.invoke(
|
| 41 |
+
cli,
|
| 42 |
+
["inference", "predict", sample_image_path, "--class-names", "cat,dog"],
|
| 43 |
+
)
|
| 44 |
+
assert result.exit_code == 0
|
| 45 |
+
assert "Predicted class:" in result.output
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# Testing of the resize_cli of the transform group
|
| 49 |
+
def test_resize_cli(runner, sample_image_path):
|
| 50 |
+
"""Tests the command-line interface resize command."""
|
| 51 |
+
result = runner.invoke(cli, ["transform", "resize", sample_image_path, "32", "32"])
|
| 52 |
+
assert result.exit_code == 0
|
| 53 |
+
assert "32" in result.output
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_resize_cli_invalid_width(runner, sample_image_path):
|
| 57 |
+
"""Tests resize with invalid width."""
|
| 58 |
+
result = runner.invoke(cli, ["transform", "resize", sample_image_path, "0", "32"])
|
| 59 |
+
assert result.exit_code == 0
|
| 60 |
+
assert "Error" in result.output
|
tests/test_logic.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit Testing of the application's logic
|
| 3 |
+
"""
|
| 4 |
+
import pytest
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from PIL import Image
|
| 7 |
+
from mylib.classifier import predict, resize
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@pytest.fixture
|
| 11 |
+
def sample_image_path(tmp_path):
|
| 12 |
+
"""Create a temporary sample image for testing."""
|
| 13 |
+
img = Image.new('RGB', (100, 100), color='blue')
|
| 14 |
+
img_path = tmp_path / "sample.jpg"
|
| 15 |
+
img.save(img_path)
|
| 16 |
+
return str(img_path)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_predict_with_file_path(sample_image_path):
|
| 20 |
+
"""Test predict function with file path."""
|
| 21 |
+
result = predict(sample_image_path, ['cat', 'dog'])
|
| 22 |
+
assert result in ['cat', 'dog']
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_predict_with_pil_image():
|
| 26 |
+
"""Test predict function with PIL Image."""
|
| 27 |
+
img = Image.new('RGB', (100, 100), color='green')
|
| 28 |
+
result = predict(img, ['cat', 'dog', 'bird'])
|
| 29 |
+
assert result in ['cat', 'dog', 'bird']
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_predict_default_classes(sample_image_path):
|
| 33 |
+
"""Test predict with default class names."""
|
| 34 |
+
result = predict(sample_image_path)
|
| 35 |
+
default_classes = ['cardboard', 'paper', 'plastic', 'metal', 'trash', 'glass']
|
| 36 |
+
assert result in default_classes
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_predict_file_not_found():
|
| 40 |
+
"""Test predict with non-existent file."""
|
| 41 |
+
with pytest.raises(FileNotFoundError):
|
| 42 |
+
predict("nonexistent.jpg", ['cat'])
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_resize_with_file_path(sample_image_path):
|
| 46 |
+
"""Test resize function with file path."""
|
| 47 |
+
result = resize(sample_image_path, 32, 32)
|
| 48 |
+
assert result == (32, 32)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_resize_with_pil_image():
|
| 52 |
+
"""Test resize function with PIL Image."""
|
| 53 |
+
img = Image.new('RGB', (100, 100), color='yellow')
|
| 54 |
+
result = resize(img, 64, 64)
|
| 55 |
+
assert result == (64, 64)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_resize_invalid_width(sample_image_path):
|
| 59 |
+
"""Test resize with invalid width."""
|
| 60 |
+
with pytest.raises(ValueError, match="'width' must be a positive integer"):
|
| 61 |
+
resize(sample_image_path, 0, 32)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_resize_invalid_height(sample_image_path):
|
| 65 |
+
"""Test resize with invalid height."""
|
| 66 |
+
with pytest.raises(ValueError, match="'height' must be a positive integer"):
|
| 67 |
+
resize(sample_image_path, 32, -5)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_resize_file_not_found():
|
| 71 |
+
"""Test resize with non-existent file."""
|
| 72 |
+
with pytest.raises(FileNotFoundError):
|
| 73 |
+
resize("nonexistent.jpg", 32, 32)
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|