diff --git a/code-smells-project/.claude/skills/refactor-arch/SKILL.md b/code-smells-project/.claude/skills/refactor-arch/SKILL.md new file mode 100644 index 000000000..b793c10e2 --- /dev/null +++ b/code-smells-project/.claude/skills/refactor-arch/SKILL.md @@ -0,0 +1,44 @@ +--- +name: refactor-arch +description: Refactor the architecture of the project MVC to improve performance, maintainability, and scalability independently of stack technology. +--- + +# Refactor Architecture + +summary: Refactor the architecture of the project to improve performance, maintainability, and scalability independently of stack technology. +general: Save all output reports in the `/docs` folder of the project. + +## Phase 1: Detect tecnology stack and architecture +- Detect the technology stack used in the project (e.g., programming languages, frameworks, libraries, databases, etc.). + +### Input +- Project source code and configuration files. + +### Output (Sample) +- [project_analises_tpl](./templates/project_analysis.txt) + + +## Phase 2: Detect code smells and architecture issues antipatterns +- Detect code smells and architecture issues antipatterns in the project. +- Order findings by severity level (Critical, High, Medium, Low) based on the [issues_severity_ref](./references/issues_severity.md). +- Identify minimal of 5 code smells and architecture issues antipatterns in the project. +- Detect deprecated APIs if aplicable. + +### Input +- Project source code and configuration files. +- [issues_severity_ref](./references/issues_severity.md) + +### Output (Sample) +- [project_issues_tpl_report](./templates/project_issues.txt) + +- Present the findings in a structured format, including the severity level, description, and location of each issue. +- Ask the user to confirm to proceed to phase 3 (Refactor the architecture) or to stop the process. + +## Phase 3: Refactor the architecture +- Refactor the project fixing the detected code smells and architecture issues antipatterns. +- Refactor the project to adopt **MVC (Model-View-Controller)** architecture pattern, ensuring a clear separation of concerns between the Model, View, and Controller components. +- Validate the refactored code to ensure that project functionallity is preserved and that the project still run. +- Update the `README.md` and `AGENTS.md` files to reflect the new architecture and any changes made during the refactoring process. + +### Output (Sample of the refactored code report) +- [project_refactored_tpl_report](./templates/project_refactored.txt) diff --git a/code-smells-project/.claude/skills/refactor-arch/references/issues_severity.md b/code-smells-project/.claude/skills/refactor-arch/references/issues_severity.md new file mode 100644 index 000000000..a4d598c44 --- /dev/null +++ b/code-smells-project/.claude/skills/refactor-arch/references/issues_severity.md @@ -0,0 +1,6 @@ +# Severity Levels for Code Issues + +- **CRITICAL**: Falhas graves de arquitetura ou segurança que impedem o funcionamento correto, expõem dados sensíveis (ex: credenciais hardcoded, SQL Injection) ou violam completamente a separação de responsabilidades (ex: "God Class" contendo banco de dados, lógicas complexas e roteamento no mesmo arquivo). +- **HIGH**: Fortes violações do padrão MVC ou princípios SOLID que dificultam muito a manutenção e testes (ex: lógicas de negócio pesadas presas dentro de Controllers, forte acoplamento sem Injeção de Dependência, ou uso de estado global mutável em toda a aplicação). +- **MEDIUM**: Problemas de padronização, duplicação de código ou gargalos de performance moderada (ex: Queries N+1 no banco de dados, uso inadequado de middlewares, validações ausentes nas rotas). +- **LOW**: Melhorias de legibilidade, nomenclatura de variáveis ruins, ou "magic numbers" soltos pelo código. diff --git a/code-smells-project/.claude/skills/refactor-arch/templates/project_analysis.txt b/code-smells-project/.claude/skills/refactor-arch/templates/project_analysis.txt new file mode 100644 index 000000000..2587accd6 --- /dev/null +++ b/code-smells-project/.claude/skills/refactor-arch/templates/project_analysis.txt @@ -0,0 +1,11 @@ + ================================ + PHASE 1: PROJECT ANALYSIS + ================================ + Language: Python + Framework: Flask 3.1.1 + Dependencies: flask-cors + Domain: E-commerce API (produtos, pedidos, usuários) + Architecture: Monolítica — tudo em 4 arquivos, sem separação de camadas + Source files: 4 files analyzed + DB tables: produtos, usuarios, pedidos, itens_pedido + ================================================ diff --git a/code-smells-project/.claude/skills/refactor-arch/templates/project_issues.txt b/code-smells-project/.claude/skills/refactor-arch/templates/project_issues.txt new file mode 100644 index 000000000..789dcd96c --- /dev/null +++ b/code-smells-project/.claude/skills/refactor-arch/templates/project_issues.txt @@ -0,0 +1,17 @@ +================================ + PHASE 2: CODE SMELLS DETECTION + ================================ + Code Smells Detected: + - Long Method: 2 instances + - Large Class: 1 instance + - Duplicated Code: 3 instances + - God Object: 1 instance + - Feature Envy: 2 instances + - Shotgun Surgery: 1 instance + + Architecture Issues Detected: + - Tight Coupling between modules + - Lack of Separation of Concerns + - Inconsistent Naming Conventions + - Poor Error Handling and Logging + ================================================ diff --git a/code-smells-project/.claude/skills/refactor-arch/templates/refactoring_results.txt b/code-smells-project/.claude/skills/refactor-arch/templates/refactoring_results.txt new file mode 100644 index 000000000..1eebd33dc --- /dev/null +++ b/code-smells-project/.claude/skills/refactor-arch/templates/refactoring_results.txt @@ -0,0 +1,24 @@ +[... refatoração executada ...] + +================================ +PHASE 3: REFACTORING COMPLETE +================================ +New Project Structure: +src/ +├── config/settings.py +├── models/ +│ ├── produto_model.py +│ └── usuario_model.py +├── views/ +│ └── routes.py +├── controllers/ +│ ├── produto_controller.py +│ └── pedido_controller.py +├── middlewares/error_handler.py +└── app.py (composition root) + +Validation + ✓ Application boots without errors + ✓ All endpoints respond correctly + ✓ Zero anti-patterns remaining +================================ diff --git a/code-smells-project/.cursor b/code-smells-project/.cursor new file mode 120000 index 000000000..c8161850a --- /dev/null +++ b/code-smells-project/.cursor @@ -0,0 +1 @@ +.claude \ No newline at end of file diff --git a/code-smells-project/AGENTS.md b/code-smells-project/AGENTS.md new file mode 100644 index 000000000..e933791fb --- /dev/null +++ b/code-smells-project/AGENTS.md @@ -0,0 +1,196 @@ +# AGENTS.md — code-smells-project + +Instructions for AI coding agents working in this repository. + +This project is a Flask e-commerce API used for the `refactor-arch` challenge. It has been **refactored to MVC** with a service layer. Prefer extending the layered design over reintroducing god modules or string-built SQL. + +Authoritative style/security reference: [`python-development-guidelines.md`](./python-development-guidelines.md). +Human runbook: [`README.md`](./README.md). +Skill reports: [`docs/`](./docs/). + +--- + +## 1. Mission + +| Goal | Detail | +|------|--------| +| Domain | E-commerce API: produtos, usuários, pedidos, relatório de vendas | +| Architecture | MVC + services (`src/`) | +| Success | Parameterized SQL, no secrets in responses, hashed passwords, testable services | + +Preserve API paths and JSON field names unless the task explicitly changes the contract. + +--- + +## 2. Stack + +| Layer | Choice | Notes | +|-------|--------|-------| +| Language | Python 3.12+ | | +| Web | Flask `3.1.1` | App factory in `src/app.py` | +| CORS | flask-cors `5.0.1` | | +| DB | SQLite (`loja.db`) | Per-request connection via Flask `g` | +| Persistence | `sqlite3` + parameterized SQL | No ORM | +| Passwords | `werkzeug.security` | Hashed at rest | +| Tests | pytest | `tests/unit`, `tests/integration` | + +```text +flask==3.1.1 +flask-cors==5.0.1 +``` + +Dev tools: `requirements-dev.txt` (`pytest`, `ruff`). + +--- + +## 3. How to run + +```bash +cd code-smells-project +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -r requirements.txt +python app.py +``` + +Prefer `.venv` (uv default). If you see `VIRTUAL_ENV=venv does not match ... .venv`, deactivate the old env and activate `.venv`, or run via `uv run`. + +- Default bind: `http://127.0.0.1:5003` +- Override with `HOST`, `PORT`, `SECRET_KEY`, `FLASK_DEBUG`, `DB_PATH`, `AMBIENTE`, `ADMIN_TOKEN` + +```bash +pip install -r requirements-dev.txt +pytest -q +``` + +Do not commit `.venv/`, `venv/`, `__pycache__/`, or local DB changes unless asked. + +--- + +## 4. Repository map + +```text +code-smells-project/ +├── app.py # Entrypoint +├── src/ +│ ├── app.py # create_app composition root +│ ├── config/settings.py +│ ├── db/database.py # schema, seed, request-scoped connection +│ ├── models/ # Model / persistence +│ ├── services/ # Business rules +│ ├── controllers/ # HTTP adapters +│ ├── views/routes.py # Route registration +│ └── middlewares/ # Error handlers +├── tests/ +├── docs/ # refactor-arch phase reports +├── requirements.txt +├── requirements-dev.txt +├── pyproject.toml +├── README.md +├── AGENTS.md +└── python-development-guidelines.md +``` + +--- + +## 5. Architecture + +```text +View (routes) + → Controller (HTTP) + → Service (domain rules) + → Model (parameterized SQL) + → get_db() per request + → loja.db +``` + +| Package | Role | +|---------|------| +| `src/views` | URLs → controller callables | +| `src/controllers` | Parse request, status codes, JSON envelope | +| `src/services` | Validation, stock, totals, auth, discounts, notifications | +| `src/models` | Repositories + row mappers (no password in public mappers) | +| `src/db` | Connection lifecycle, DDL, seed, password migration | +| `src/config` | Env-based settings | + +--- + +## 6. Data model + +Unchanged tables: `produtos`, `usuarios`, `pedidos`, `itens_pedido`. + +- Categorias: `informatica`, `moveis`, `vestuario`, `geral`, `eletronicos`, `livros` +- Status pedido: `pendente`, `aprovado`, `enviado`, `entregue`, `cancelado` +- Seed users (plaintext only for login; stored hashed): + +| Email | Password | Tipo | +|-------|----------|------| +| `admin@loja.com` | `admin123` | `admin` | +| `joao@email.com` | `123456` | `cliente` | +| `maria@email.com` | `senha123` | `cliente` | + +Foreign keys enabled (`PRAGMA foreign_keys = ON`). + +--- + +## 7. HTTP API surface + +Base URL: `http://127.0.0.1:5003` + +Public paths unchanged: `/`, `/health`, `/produtos`, `/usuarios`, `/login`, `/pedidos`, `/relatorios/vendas`. + +| Admin | Behavior | +|-------|----------| +| `POST /admin/query` | **Removed** | +| `POST /admin/reset-db` | Requires `X-Admin-Token: $ADMIN_TOKEN` | + +`/health` returns status, counts, versão, ambiente — **never** `secret_key`, passwords, or `db_path`. + +--- + +## 8. Fixed issues (do not reintroduce) + +1. SQL concatenation → use `?` placeholders only +2. Arbitrary SQL admin endpoint → removed +3. Secrets in `/health` / hardcoded production secret → env config +4. Plaintext passwords / `senha` in list/get → hashed + omitted from responses +5. God `models.py` / fat controllers → split by domain + services +6. Global `db_connection` → Flask `g` per request +7. N+1 on pedidos → JOIN load of itens +8. `print` logging → `logging` module + +--- + +## 9. Coding standards + +Follow [`python-development-guidelines.md`](./python-development-guidelines.md). + +- Parameterized SQL only +- Thin controllers; rules in services +- `logging.getLogger(__name__)`; never log passwords +- Secrets from environment +- Add pytest coverage for behavior you change + +--- + +## 10. Working agreements + +| Topic | Rule | +|-------|------| +| Language in code | Portuguese identifiers / user-facing messages | +| Commits | Only when the user asks | +| Scope | Change only files required by the task | + +### Smoke checks + +```bash +curl -s http://127.0.0.1:5003/health +curl -s http://127.0.0.1:5003/produtos +curl -s -X POST http://127.0.0.1:5003/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"joao@email.com","senha":"123456"}' +``` + +--- + +*Aligned with MVC refactor (v2.0.0), Flask 3.1.1, and `python-development-guidelines.md`.* diff --git a/code-smells-project/README.md b/code-smells-project/README.md index 72b798a6e..f9b88c2b3 100644 --- a/code-smells-project/README.md +++ b/code-smells-project/README.md @@ -1,12 +1,65 @@ # code-smells-project -API de E-commerce em Python/Flask usada como entrada do desafio `refactor-arch`. +API de E-commerce em Python/Flask refatorada para **MVC** (com camada de serviços) no desafio `refactor-arch`. ## Como rodar ```bash +python3 -m venv .venv +source .venv/bin/activate pip install -r requirements.txt python app.py ``` -A aplicação sobe em `http://localhost:5000`. O banco SQLite (`loja.db`) é criado automaticamente no primeiro boot, já com produtos e usuários de exemplo. +Or with uv (uses `.venv` automatically): + +```bash +uv sync +uv run python app.py +``` + +A aplicação sobe em `http://127.0.0.1:5003` por padrão. O banco SQLite (`loja.db`) é criado/migrado no boot, com produtos e usuários de exemplo. + +### Variáveis de ambiente + +| Variável | Default | Descrição | +|----------|---------|-----------| +| `SECRET_KEY` | `dev-only-change-me` | Chave Flask (não expor) | +| `FLASK_DEBUG` | `0` | `1` habilita debug | +| `HOST` | `127.0.0.1` | Bind address | +| `PORT` | `5003` | Porta HTTP | +| `DB_PATH` | `loja.db` | Caminho do SQLite | +| `AMBIENTE` | `desenvolvimento` | Label no `/health` | +| `ADMIN_TOKEN` | _(vazio)_ | Token para `POST /admin/reset-db` | + +## Arquitetura (MVC) + +```text +HTTP → views/routes.py → controllers/ → services/ → models/ → db (SQLite) +``` + +| Camada | Pacote | Responsabilidade | +|--------|--------|------------------| +| View | `src/views` | Registro de rotas | +| Controller | `src/controllers` | HTTP in/out | +| Service | `src/services` | Regras de negócio | +| Model | `src/models` | SQL parametrizado | +| Config/DB | `src/config`, `src/db` | Settings e conexão por request | + +## Testes + +```bash +pip install -r requirements-dev.txt +pytest -q +``` + +## Endpoints + +Mesmos paths da API original (`/produtos`, `/usuarios`, `/pedidos`, `/login`, `/relatorios/vendas`, `/health`). + +- `POST /admin/query` **removido** (SQL arbitrário). +- `POST /admin/reset-db` exige header `X-Admin-Token` igual a `ADMIN_TOKEN`. + +Usuários seed: `joao@email.com` / `123456` (senhas agora hasheadas). + +Relatórios da skill: pasta [`docs/`](./docs/). diff --git a/code-smells-project/app.py b/code-smells-project/app.py index 70458e653..8fb55636d 100644 --- a/code-smells-project/app.py +++ b/code-smells-project/app.py @@ -1,88 +1,18 @@ -from flask import Flask, jsonify, request -from flask_cors import CORS -import controllers -from database import get_db +"""Application entrypoint.""" -app = Flask(__name__) -app.config["SECRET_KEY"] = "minha-chave-super-secreta-123" -app.config["DEBUG"] = True -CORS(app) +from __future__ import annotations -app.add_url_rule("/produtos", "listar_produtos", controllers.listar_produtos, methods=["GET"]) -app.add_url_rule("/produtos/busca", "buscar_produtos", controllers.buscar_produtos, methods=["GET"]) -app.add_url_rule("/produtos/", "buscar_produto", controllers.buscar_produto, methods=["GET"]) -app.add_url_rule("/produtos", "criar_produto", controllers.criar_produto, methods=["POST"]) -app.add_url_rule("/produtos/", "atualizar_produto", controllers.atualizar_produto, methods=["PUT"]) -app.add_url_rule("/produtos/", "deletar_produto", controllers.deletar_produto, methods=["DELETE"]) +from src.app import create_app +from src.config.settings import load_settings +from src.db.database import init_db -app.add_url_rule("/usuarios", "listar_usuarios", controllers.listar_usuarios, methods=["GET"]) -app.add_url_rule("/usuarios/", "buscar_usuario", controllers.buscar_usuario, methods=["GET"]) -app.add_url_rule("/usuarios", "criar_usuario", controllers.criar_usuario, methods=["POST"]) -app.add_url_rule("/login", "login", controllers.login, methods=["POST"]) - -app.add_url_rule("/pedidos", "criar_pedido", controllers.criar_pedido, methods=["POST"]) -app.add_url_rule("/pedidos", "listar_todos_pedidos", controllers.listar_todos_pedidos, methods=["GET"]) -app.add_url_rule("/pedidos/usuario/", "listar_pedidos_usuario", controllers.listar_pedidos_usuario, methods=["GET"]) -app.add_url_rule("/pedidos//status", "atualizar_status_pedido", controllers.atualizar_status_pedido, methods=["PUT"]) - -app.add_url_rule("/relatorios/vendas", "relatorio_vendas", controllers.relatorio_vendas, methods=["GET"]) - -app.add_url_rule("/health", "health_check", controllers.health_check, methods=["GET"]) - -@app.route("/") -def index(): - return jsonify({ - "mensagem": "Bem-vindo à API da Loja", - "versao": "1.0.0", - "endpoints": { - "produtos": "/produtos", - "usuarios": "/usuarios", - "pedidos": "/pedidos", - "login": "/login", - "relatorios": "/relatorios/vendas", - "health": "/health" - } - }) - -@app.route("/admin/reset-db", methods=["POST"]) -def reset_database(): - db = get_db() - cursor = db.cursor() - cursor.execute("DELETE FROM itens_pedido") - cursor.execute("DELETE FROM pedidos") - cursor.execute("DELETE FROM produtos") - cursor.execute("DELETE FROM usuarios") - db.commit() - print("!!! BANCO DE DADOS RESETADO !!!") - return jsonify({"mensagem": "Banco de dados resetado", "sucesso": True}), 200 - -@app.route("/admin/query", methods=["POST"]) -def executar_query(): - dados = request.get_json() - query = dados.get("sql", "") - if not query: - return jsonify({"erro": "Query não informada"}), 400 - - db = get_db() - cursor = db.cursor() - try: - cursor.execute(query) - if query.strip().upper().startswith("SELECT"): - rows = cursor.fetchall() - result = [dict(row) for row in rows] - return jsonify({"dados": result, "sucesso": True}), 200 - else: - db.commit() - return jsonify({"mensagem": "Query executada", "sucesso": True}), 200 - except Exception as e: - return jsonify({"erro": str(e)}), 500 +settings = load_settings() +init_db(settings) +app = create_app(settings) if __name__ == "__main__": - - get_db() print("=" * 50) print("SERVIDOR INICIADO") - print("Rodando em http://localhost:5000") + print(f"Rodando em http://{settings.host}:{settings.port}") print("=" * 50) - - app.run(host="0.0.0.0", port=5000, debug=True) + app.run(host=settings.host, port=settings.port, debug=settings.debug) diff --git a/code-smells-project/controllers.py b/code-smells-project/controllers.py deleted file mode 100644 index 51ca25477..000000000 --- a/code-smells-project/controllers.py +++ /dev/null @@ -1,292 +0,0 @@ -from flask import request, jsonify -import models -from database import get_db - -def listar_produtos(): - try: - produtos = models.get_todos_produtos() - print("Listando " + str(len(produtos)) + " produtos") - return jsonify({"dados": produtos, "sucesso": True}), 200 - except Exception as e: - print("ERRO: " + str(e)) - return jsonify({"erro": str(e)}), 500 - -def buscar_produto(id): - try: - produto = models.get_produto_por_id(id) - if produto: - return jsonify({"dados": produto, "sucesso": True}), 200 - else: - return jsonify({"erro": "Produto não encontrado", "sucesso": False}), 404 - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def criar_produto(): - try: - dados = request.get_json() - - if not dados: - return jsonify({"erro": "Dados inválidos"}), 400 - if "nome" not in dados: - return jsonify({"erro": "Nome é obrigatório"}), 400 - if "preco" not in dados: - return jsonify({"erro": "Preço é obrigatório"}), 400 - if "estoque" not in dados: - return jsonify({"erro": "Estoque é obrigatório"}), 400 - - nome = dados["nome"] - descricao = dados.get("descricao", "") - preco = dados["preco"] - estoque = dados["estoque"] - categoria = dados.get("categoria", "geral") - - if preco < 0: - return jsonify({"erro": "Preço não pode ser negativo"}), 400 - if estoque < 0: - return jsonify({"erro": "Estoque não pode ser negativo"}), 400 - if len(nome) < 2: - return jsonify({"erro": "Nome muito curto"}), 400 - if len(nome) > 200: - return jsonify({"erro": "Nome muito longo"}), 400 - - categorias_validas = ["informatica", "moveis", "vestuario", "geral", "eletronicos", "livros"] - if categoria not in categorias_validas: - return jsonify({"erro": "Categoria inválida. Válidas: " + str(categorias_validas)}), 400 - - id = models.criar_produto(nome, descricao, preco, estoque, categoria) - print("Produto criado com ID: " + str(id)) - return jsonify({"dados": {"id": id}, "sucesso": True, "mensagem": "Produto criado"}), 201 - - except Exception as e: - print("ERRO ao criar produto: " + str(e)) - return jsonify({"erro": str(e)}), 500 - -def atualizar_produto(id): - try: - dados = request.get_json() - - produto_existente = models.get_produto_por_id(id) - if not produto_existente: - return jsonify({"erro": "Produto não encontrado"}), 404 - - if not dados: - return jsonify({"erro": "Dados inválidos"}), 400 - if "nome" not in dados: - return jsonify({"erro": "Nome é obrigatório"}), 400 - if "preco" not in dados: - return jsonify({"erro": "Preço é obrigatório"}), 400 - if "estoque" not in dados: - return jsonify({"erro": "Estoque é obrigatório"}), 400 - - nome = dados["nome"] - descricao = dados.get("descricao", "") - preco = dados["preco"] - estoque = dados["estoque"] - categoria = dados.get("categoria", "geral") - - if preco < 0: - return jsonify({"erro": "Preço não pode ser negativo"}), 400 - if estoque < 0: - return jsonify({"erro": "Estoque não pode ser negativo"}), 400 - - models.atualizar_produto(id, nome, descricao, preco, estoque, categoria) - return jsonify({"sucesso": True, "mensagem": "Produto atualizado"}), 200 - - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def deletar_produto(id): - try: - - produto = models.get_produto_por_id(id) - if not produto: - return jsonify({"erro": "Produto não encontrado"}), 404 - - models.deletar_produto(id) - print("Produto " + str(id) + " deletado") - return jsonify({"sucesso": True, "mensagem": "Produto deletado"}), 200 - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def buscar_produtos(): - try: - termo = request.args.get("q", "") - categoria = request.args.get("categoria", None) - preco_min = request.args.get("preco_min", None) - preco_max = request.args.get("preco_max", None) - - if preco_min: - preco_min = float(preco_min) - if preco_max: - preco_max = float(preco_max) - - resultados = models.buscar_produtos(termo, categoria, preco_min, preco_max) - return jsonify({"dados": resultados, "total": len(resultados), "sucesso": True}), 200 - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def listar_usuarios(): - try: - usuarios = models.get_todos_usuarios() - - return jsonify({"dados": usuarios, "sucesso": True}), 200 - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def buscar_usuario(id): - try: - usuario = models.get_usuario_por_id(id) - if usuario: - return jsonify({"dados": usuario, "sucesso": True}), 200 - else: - return jsonify({"erro": "Usuário não encontrado"}), 404 - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def criar_usuario(): - try: - dados = request.get_json() - - if not dados: - return jsonify({"erro": "Dados inválidos"}), 400 - - nome = dados.get("nome", "") - email = dados.get("email", "") - senha = dados.get("senha", "") - - if not nome or not email or not senha: - return jsonify({"erro": "Nome, email e senha são obrigatórios"}), 400 - - id = models.criar_usuario(nome, email, senha) - print("Usuário criado: " + email) - return jsonify({"dados": {"id": id}, "sucesso": True}), 201 - - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def login(): - try: - dados = request.get_json() - email = dados.get("email", "") - senha = dados.get("senha", "") - - if not email or not senha: - return jsonify({"erro": "Email e senha são obrigatórios"}), 400 - - usuario = models.login_usuario(email, senha) - if usuario: - - print("Login bem-sucedido: " + email) - return jsonify({"dados": usuario, "sucesso": True, "mensagem": "Login OK"}), 200 - else: - print("Login falhou: " + email) - return jsonify({"erro": "Email ou senha inválidos", "sucesso": False}), 401 - - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def criar_pedido(): - try: - dados = request.get_json() - - if not dados: - return jsonify({"erro": "Dados inválidos"}), 400 - - usuario_id = dados.get("usuario_id") - itens = dados.get("itens", []) - - if not usuario_id: - return jsonify({"erro": "Usuario ID é obrigatório"}), 400 - if not itens or len(itens) == 0: - return jsonify({"erro": "Pedido deve ter pelo menos 1 item"}), 400 - - resultado = models.criar_pedido(usuario_id, itens) - - if "erro" in resultado: - return jsonify({"erro": resultado["erro"], "sucesso": False}), 400 - - print("ENVIANDO EMAIL: Pedido " + str(resultado["pedido_id"]) + " criado para usuario " + str(usuario_id)) - print("ENVIANDO SMS: Seu pedido foi recebido!") - print("ENVIANDO PUSH: Novo pedido recebido pelo sistema") - - return jsonify({ - "dados": resultado, - "sucesso": True, - "mensagem": "Pedido criado com sucesso" - }), 201 - - except Exception as e: - print("ERRO CRITICO ao criar pedido: " + str(e)) - return jsonify({"erro": str(e)}), 500 - -def listar_pedidos_usuario(usuario_id): - try: - pedidos = models.get_pedidos_usuario(usuario_id) - return jsonify({"dados": pedidos, "sucesso": True}), 200 - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def listar_todos_pedidos(): - try: - - pedidos = models.get_todos_pedidos() - return jsonify({"dados": pedidos, "sucesso": True}), 200 - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def atualizar_status_pedido(pedido_id): - try: - dados = request.get_json() - novo_status = dados.get("status", "") - - if novo_status not in ["pendente", "aprovado", "enviado", "entregue", "cancelado"]: - return jsonify({"erro": "Status inválido"}), 400 - - models.atualizar_status_pedido(pedido_id, novo_status) - - if novo_status == "aprovado": - print("NOTIFICAÇÃO: Pedido " + str(pedido_id) + " foi aprovado! Preparar envio.") - if novo_status == "cancelado": - print("NOTIFICAÇÃO: Pedido " + str(pedido_id) + " cancelado. Devolver estoque.") - - return jsonify({"sucesso": True, "mensagem": "Status atualizado"}), 200 - - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def relatorio_vendas(): - try: - relatorio = models.relatorio_vendas() - return jsonify({"dados": relatorio, "sucesso": True}), 200 - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def health_check(): - try: - db = get_db() - cursor = db.cursor() - cursor.execute("SELECT 1") - cursor.execute("SELECT COUNT(*) FROM produtos") - produtos = cursor.fetchone()[0] - cursor.execute("SELECT COUNT(*) FROM usuarios") - usuarios = cursor.fetchone()[0] - cursor.execute("SELECT COUNT(*) FROM pedidos") - pedidos = cursor.fetchone()[0] - - return jsonify({ - "status": "ok", - "database": "connected", - "counts": { - "produtos": produtos, - "usuarios": usuarios, - "pedidos": pedidos - }, - - "versao": "1.0.0", - "ambiente": "producao", - "db_path": "loja.db", - "debug": True, - "secret_key": "minha-chave-super-secreta-123" - }), 200 - except Exception as e: - return jsonify({"status": "erro", "detalhes": str(e)}), 500 diff --git a/code-smells-project/database.py b/code-smells-project/database.py deleted file mode 100644 index 798587644..000000000 --- a/code-smells-project/database.py +++ /dev/null @@ -1,86 +0,0 @@ -import sqlite3 -import os - -db_connection = None -db_path = "loja.db" - -def get_db(): - global db_connection - if db_connection is None: - db_connection = sqlite3.connect(db_path, check_same_thread=False) - db_connection.row_factory = sqlite3.Row - cursor = db_connection.cursor() - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS produtos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - nome TEXT, - descricao TEXT, - preco REAL, - estoque INTEGER, - categoria TEXT, - ativo INTEGER DEFAULT 1, - criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS usuarios ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - nome TEXT, - email TEXT, - senha TEXT, - tipo TEXT DEFAULT 'cliente', - criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS pedidos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - usuario_id INTEGER, - status TEXT DEFAULT 'pendente', - total REAL, - criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS itens_pedido ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - pedido_id INTEGER, - produto_id INTEGER, - quantidade INTEGER, - preco_unitario REAL - ) - """) - db_connection.commit() - - cursor.execute("SELECT COUNT(*) FROM produtos") - if cursor.fetchone()[0] == 0: - produtos = [ - ("Notebook Gamer", "Notebook potente para jogos", 5999.99, 10, "informatica"), - ("Mouse Wireless", "Mouse sem fio ergonômico", 89.90, 50, "informatica"), - ("Teclado Mecânico", "Teclado mecânico RGB", 299.90, 30, "informatica"), - ("Monitor 27''", "Monitor 27 polegadas 144hz", 1899.90, 15, "informatica"), - ("Headset Gamer", "Headset com microfone", 199.90, 25, "informatica"), - ("Cadeira Gamer", "Cadeira ergonômica", 1299.90, 8, "moveis"), - ("Webcam HD", "Webcam 1080p", 249.90, 20, "informatica"), - ("Hub USB", "Hub USB 3.0 7 portas", 79.90, 40, "informatica"), - ("SSD 1TB", "SSD NVMe 1TB", 449.90, 35, "informatica"), - ("Camiseta Dev", "Camiseta estampa código", 59.90, 100, "vestuario"), - ] - cursor.executemany( - "INSERT INTO produtos (nome, descricao, preco, estoque, categoria) VALUES (?, ?, ?, ?, ?)", - produtos - ) - - usuarios = [ - ("Admin", "admin@loja.com", "admin123", "admin"), - ("João Silva", "joao@email.com", "123456", "cliente"), - ("Maria Santos", "maria@email.com", "senha123", "cliente"), - ] - cursor.executemany( - "INSERT INTO usuarios (nome, email, senha, tipo) VALUES (?, ?, ?, ?)", - usuarios - ) - db_connection.commit() - - return db_connection diff --git a/code-smells-project/docs/project_analysis.txt b/code-smells-project/docs/project_analysis.txt new file mode 100644 index 000000000..305c6f352 --- /dev/null +++ b/code-smells-project/docs/project_analysis.txt @@ -0,0 +1,42 @@ +================================ + PHASE 1: PROJECT ANALYSIS + ================================ + Language: Python 3 (runtime observed: 3.14) + Framework: Flask 3.1.1 + Dependencies: flask-cors 5.0.1 + Domain: E-commerce API (produtos, pedidos, usuários, relatórios) + Architecture: Monolítica — pseudo-MVC quebrado; 4 módulos sem camadas claras + Source files: 4 files analyzed (app.py, controllers.py, models.py, database.py) + Total LOC: ~780 lines + Database: SQLite (loja.db) via sqlite3 stdlib — sem ORM + DB tables: produtos, usuarios, pedidos, itens_pedido + Bind: 0.0.0.0:5003 (debug=True) + Tests: Nenhum + ================================================ + + STRUCTURE + --------- + app.py Flask app, registro de rotas, endpoints admin inseguros + controllers.py Handlers HTTP (validação + efeitos colaterais + SQL no health) + models.py Acesso a dados + regras de negócio + relatórios (God Module) + database.py Conexão global, schema DDL e seed + + CURRENT FLOW + ------------ + HTTP (app.py / controllers.py) + │ + ▼ + models.py ← SQL + domínio + reporting + │ + ▼ + database.get_db() ← conexão global mutável + │ + ▼ + loja.db + + NOTES + ----- + - README indica porta 5000; código usa 5003. + - Camada View inexistente (API JSON apenas — esperado para API REST). + - "models" não são entidades de domínio; são funções de persistência. + ================================================ diff --git a/code-smells-project/docs/project_issues.txt b/code-smells-project/docs/project_issues.txt new file mode 100644 index 000000000..4c637c148 --- /dev/null +++ b/code-smells-project/docs/project_issues.txt @@ -0,0 +1,129 @@ +================================ + PHASE 2: CODE SMELLS DETECTION + ================================ + Ordered by severity (Critical → Low). + Reference: issues_severity.md + + SUMMARY + ------- + Code Smells Detected: + - God Object / Large Module: 1 instance (models.py) + - Long Method: 3+ instances (criar_produto, criar_pedido, get_pedidos_*, relatorio_vendas) + - Duplicated Code: 4+ instances (validação produto, row→dict, carga de pedidos) + - Feature Envy: controllers acessam DB direto (health_check); models misturam SQL+domínio + - Primitive Obsession / Magic Numbers: descontos em relatorio_vendas + - Shotgun Surgery risk: regras de produto/pedido espalhadas em controllers + models + + Architecture Issues Detected: + - Lack of Separation of Concerns (MVC quebrado) + - Tight Coupling (controllers ↔ models ↔ global DB) + - Business Logic in Controllers + - Global Mutable State (db_connection) + - Poor Error Handling and Logging (print / Exception genérica) + - Inconsistent Naming / contract docs (porta README vs app) + + Deprecated / risky APIs: + - Flask __version__ deprecated (Flask 3.2+); not used in app code, but noted + - debug=True + host 0.0.0.0 in entrypoint (unsafe for any non-local deploy) + - Raw string-built SQL everywhere (not deprecated API, but obsolete/unsafe pattern) + + ================================================ + + DETAILED FINDINGS + ----------------- + + [CRITICAL] SQL Injection (string-concatenated queries) + Location: models.py (get_produto_por_id, criar_produto, atualizar_produto, + deletar_produto, login_usuario, criar_usuario, criar_pedido, + get_pedidos_usuario, get_todos_pedidos, atualizar_status_pedido, + buscar_produtos, etc.) + Description: Queries built with string concatenation instead of parameterized + statements (? placeholders). Attackers can inject SQL via IDs, + names, email, search terms, status, etc. + + [CRITICAL] Arbitrary SQL execution endpoint (RCE-equivalent on DB) + Location: app.py → executar_query (/admin/query) + Description: Accepts arbitrary SQL from JSON body with no auth. Allows full + read/write/DDL on loja.db. + + [CRITICAL] Unauthenticated destructive admin endpoint + Location: app.py → reset_database (/admin/reset-db) + Description: Deletes all rows from all tables without authentication. + + [CRITICAL] Secrets and credentials exposure + Location: app.py (SECRET_KEY hardcoded); controllers.py health_check + (returns secret_key in JSON); database.py seed (plaintext passwords); + models.py get_todos_usuarios / get_usuario_por_id (returns senha) + Description: Hardcoded secret, passwords stored and returned in plaintext, + health endpoint leaks secret_key and internal paths. + + [CRITICAL] God Object / Large Module + mixed responsibilities + Location: models.py (~314 LOC) + Description: Single module owns products, users, orders, items, reporting, + stock updates and login — violates SRP and true Model layer. + + [HIGH] Lack of Separation of Concerns / broken MVC + Location: app.py, controllers.py, models.py, database.py + Description: Controllers hold validation + notifications; models hold SQL + + business rules; app.py holds admin/data logic; no service layer; + no distinct Model entities. Target architecture is clean MVC. + + [HIGH] Business logic and side effects in Controllers + Location: controllers.py → criar_produto, atualizar_produto, criar_pedido, + atualizar_status_pedido + Description: Validation rules, category allow-lists, and fake email/SMS/push + notifications live in HTTP handlers — hard to test/reuse. + + [HIGH] Global mutable database connection + Location: database.py → db_connection / get_db() + Description: Process-wide SQLite connection with check_same_thread=False. + Hinders testing, request isolation, and safe concurrency. + + [HIGH] Tight coupling without dependency injection + Location: controllers.py imports models + get_db; models imports get_db + Description: Layers import concrete modules directly; cannot swap persistence + or mock easily. + + [HIGH] N+1 query antipattern + Location: models.py → get_pedidos_usuario, get_todos_pedidos + Description: For each pedido, queries itens_pedido; for each item, queries + produtos. Performance degrades with dataset size. + + [MEDIUM] Duplicated Code + Location: controllers.py (criar_produto ↔ atualizar_produto validation); + models.py (row→dict for produtos/usuarios/pedidos duplicated; + get_pedidos_usuario ↔ get_todos_pedidos nearly identical) + Description: Copy-paste increases Shotgun Surgery risk on rule changes. + + [MEDIUM] Long Method + Location: controllers.criar_produto; models.criar_pedido; models.relatorio_vendas; + models.get_todos_pedidos + Description: Methods mix validation, persistence, calculation and mapping. + + [MEDIUM] Poor Error Handling and Logging + Location: controllers.py (broad except Exception + print) + Description: No structured logging, stack traces or error codes; leaks raw + exception strings to API clients. + + [MEDIUM] Missing / weak input validation + Location: controllers.criar_usuario, login, criar_pedido; models SQL paths + Description: Types rarely coerced/validated; email format unchecked; pedido + item shape lightly validated. + + [LOW] Magic numbers + Location: models.relatorio_vendas (10000/5000/1000 and 0.1/0.05/0.02) + Description: Discount thresholds hardcoded without named constants/config. + + [LOW] Inconsistent docs / naming + Location: README.md (port 5000) vs app.py (port 5003); ambiente "producao" + with debug=True in health_check + Description: Misleading operational signals for agents and humans. + + [LOW] Dead / unused import + Location: models.py → import sqlite3 + Description: Imported but unused (minor cleanliness issue). + + ================================================ + Totals: Critical=5 | High=5 | Medium=4 | Low=3 + Minimum of 5 smells/issues: satisfied (17 detailed findings). + ================================================ diff --git a/code-smells-project/docs/project_refactored.txt b/code-smells-project/docs/project_refactored.txt new file mode 100644 index 000000000..c5c3b3ec2 --- /dev/null +++ b/code-smells-project/docs/project_refactored.txt @@ -0,0 +1,60 @@ +================================ +PHASE 3: REFACTORING COMPLETE +================================ +New Project Structure: +src/ +├── app.py # composition root (create_app) +├── config/settings.py # env-based configuration +├── db/database.py # per-request SQLite + schema/seed +├── models/ +│ ├── mappers.py +│ ├── produto_model.py +│ ├── usuario_model.py +│ ├── pedido_model.py +│ └── relatorio_model.py +├── services/ +│ ├── errors.py +│ ├── produto_service.py +│ ├── usuario_service.py +│ ├── pedido_service.py +│ ├── relatorio_service.py +│ └── notificacao_service.py +├── controllers/ +│ ├── deps.py +│ ├── produto_controller.py +│ ├── usuario_controller.py +│ ├── pedido_controller.py +│ ├── relatorio_controller.py +│ └── health_controller.py +├── views/routes.py # View: URL registration +└── middlewares/error_handler.py + +app.py # entrypoint +tests/unit + tests/integration +docs/ # phase 1–3 reports + +Removed legacy god modules: + - controllers.py + - models.py + - database.py + +Issues Addressed + ✓ SQL Injection → parameterized queries everywhere + ✓ /admin/query removed + ✓ /admin/reset-db protected by ADMIN_TOKEN + ✓ SECRET_KEY from env; /health no longer leaks secrets + ✓ Passwords hashed (werkzeug); senha omitted from API + ✓ MVC + services separation of concerns + ✓ Global DB connection replaced with Flask g + ✓ N+1 pedidos fixed with JOIN + ✓ Duplicated validation/mapping consolidated + ✓ print → logging; magic discount tiers → DESCONTO_FAIXAS + ✓ README / AGENTS.md updated + +Validation + ✓ Application boots without errors + ✓ pytest: 9 passed + ✓ Endpoints respond (health, produtos, login) + ✓ /admin/query returns 404 + ✓ API paths and JSON envelopes preserved +================================ diff --git a/code-smells-project/evidencias.md b/code-smells-project/evidencias.md new file mode 100644 index 000000000..37260627a --- /dev/null +++ b/code-smells-project/evidencias.md @@ -0,0 +1,21 @@ +# Evidencias + +## Ambiente + +![Ambiente](image.png) + +## Rodando a SKILL + +![RUN_SKILL](image_1.png) + +## Resultados fase 1 e 2 + +![Resultados_1_2](image_2.png) + +## Resultados fase 3 (Refatoração) + +![Refactoring_3](image_3.png) + +## Conclusão + +![conclusao](image_4.png) diff --git a/code-smells-project/image.png b/code-smells-project/image.png new file mode 100644 index 000000000..893cd1546 Binary files /dev/null and b/code-smells-project/image.png differ diff --git a/code-smells-project/image_1.png b/code-smells-project/image_1.png new file mode 100644 index 000000000..5d2f70c6f Binary files /dev/null and b/code-smells-project/image_1.png differ diff --git a/code-smells-project/image_2.png b/code-smells-project/image_2.png new file mode 100644 index 000000000..785c91208 Binary files /dev/null and b/code-smells-project/image_2.png differ diff --git a/code-smells-project/image_3.png b/code-smells-project/image_3.png new file mode 100644 index 000000000..d8ccc0882 Binary files /dev/null and b/code-smells-project/image_3.png differ diff --git a/code-smells-project/image_4.png b/code-smells-project/image_4.png new file mode 100644 index 000000000..15a5ab2a0 Binary files /dev/null and b/code-smells-project/image_4.png differ diff --git a/code-smells-project/models.py b/code-smells-project/models.py deleted file mode 100644 index 6cf62fe2e..000000000 --- a/code-smells-project/models.py +++ /dev/null @@ -1,314 +0,0 @@ -from database import get_db -import sqlite3 - -def get_todos_produtos(): - db = get_db() - cursor = db.cursor() - cursor.execute("SELECT * FROM produtos") - rows = cursor.fetchall() - result = [] - for row in rows: - - result.append({ - "id": row["id"], - "nome": row["nome"], - "descricao": row["descricao"], - "preco": row["preco"], - "estoque": row["estoque"], - "categoria": row["categoria"], - "ativo": row["ativo"], - "criado_em": row["criado_em"] - }) - return result - -def get_produto_por_id(id): - db = get_db() - cursor = db.cursor() - - cursor.execute("SELECT * FROM produtos WHERE id = " + str(id)) - row = cursor.fetchone() - if row: - return { - "id": row["id"], - "nome": row["nome"], - "descricao": row["descricao"], - "preco": row["preco"], - "estoque": row["estoque"], - "categoria": row["categoria"], - "ativo": row["ativo"], - "criado_em": row["criado_em"] - } - return None - -def criar_produto(nome, descricao, preco, estoque, categoria): - db = get_db() - cursor = db.cursor() - - cursor.execute( - "INSERT INTO produtos (nome, descricao, preco, estoque, categoria) VALUES ('" + - nome + "', '" + descricao + "', " + str(preco) + ", " + str(estoque) + ", '" + categoria + "')" - ) - db.commit() - return cursor.lastrowid - -def atualizar_produto(id, nome, descricao, preco, estoque, categoria): - db = get_db() - cursor = db.cursor() - cursor.execute( - "UPDATE produtos SET nome = '" + nome + "', descricao = '" + descricao + - "', preco = " + str(preco) + ", estoque = " + str(estoque) + - ", categoria = '" + categoria + "' WHERE id = " + str(id) - ) - db.commit() - return True - -def deletar_produto(id): - db = get_db() - cursor = db.cursor() - cursor.execute("DELETE FROM produtos WHERE id = " + str(id)) - db.commit() - return True - -def get_todos_usuarios(): - db = get_db() - cursor = db.cursor() - cursor.execute("SELECT * FROM usuarios") - rows = cursor.fetchall() - result = [] - for row in rows: - result.append({ - "id": row["id"], - "nome": row["nome"], - "email": row["email"], - "senha": row["senha"], - "tipo": row["tipo"], - "criado_em": row["criado_em"] - }) - return result - -def get_usuario_por_id(id): - db = get_db() - cursor = db.cursor() - cursor.execute("SELECT * FROM usuarios WHERE id = " + str(id)) - row = cursor.fetchone() - if row: - return { - "id": row["id"], - "nome": row["nome"], - "email": row["email"], - "senha": row["senha"], - "tipo": row["tipo"], - "criado_em": row["criado_em"] - } - return None - -def login_usuario(email, senha): - db = get_db() - cursor = db.cursor() - - cursor.execute( - "SELECT * FROM usuarios WHERE email = '" + email + "' AND senha = '" + senha + "'" - ) - row = cursor.fetchone() - if row: - return { - "id": row["id"], - "nome": row["nome"], - "email": row["email"], - "tipo": row["tipo"] - } - return None - -def criar_usuario(nome, email, senha, tipo="cliente"): - db = get_db() - cursor = db.cursor() - - cursor.execute( - "INSERT INTO usuarios (nome, email, senha, tipo) VALUES ('" + - nome + "', '" + email + "', '" + senha + "', '" + tipo + "')" - ) - db.commit() - return cursor.lastrowid - -def criar_pedido(usuario_id, itens): - db = get_db() - cursor = db.cursor() - - total = 0 - - for item in itens: - cursor.execute("SELECT * FROM produtos WHERE id = " + str(item["produto_id"])) - produto = cursor.fetchone() - if produto is None: - return {"erro": "Produto " + str(item["produto_id"]) + " não encontrado"} - if produto["estoque"] < item["quantidade"]: - return {"erro": "Estoque insuficiente para " + produto["nome"]} - total = total + (produto["preco"] * item["quantidade"]) - - cursor.execute( - "INSERT INTO pedidos (usuario_id, status, total) VALUES (" + - str(usuario_id) + ", 'pendente', " + str(total) + ")" - ) - pedido_id = cursor.lastrowid - - for item in itens: - cursor.execute("SELECT preco FROM produtos WHERE id = " + str(item["produto_id"])) - produto = cursor.fetchone() - cursor.execute( - "INSERT INTO itens_pedido (pedido_id, produto_id, quantidade, preco_unitario) VALUES (" + - str(pedido_id) + ", " + str(item["produto_id"]) + ", " + - str(item["quantidade"]) + ", " + str(produto["preco"]) + ")" - ) - - cursor.execute( - "UPDATE produtos SET estoque = estoque - " + str(item["quantidade"]) + - " WHERE id = " + str(item["produto_id"]) - ) - - db.commit() - return {"pedido_id": pedido_id, "total": total} - -def get_pedidos_usuario(usuario_id): - db = get_db() - cursor = db.cursor() - cursor.execute("SELECT * FROM pedidos WHERE usuario_id = " + str(usuario_id)) - rows = cursor.fetchall() - result = [] - for row in rows: - pedido = { - "id": row["id"], - "usuario_id": row["usuario_id"], - "status": row["status"], - "total": row["total"], - "criado_em": row["criado_em"], - "itens": [] - } - - cursor2 = db.cursor() - cursor2.execute("SELECT * FROM itens_pedido WHERE pedido_id = " + str(row["id"])) - itens = cursor2.fetchall() - for item in itens: - cursor3 = db.cursor() - cursor3.execute("SELECT nome FROM produtos WHERE id = " + str(item["produto_id"])) - prod = cursor3.fetchone() - pedido["itens"].append({ - "produto_id": item["produto_id"], - "produto_nome": prod["nome"] if prod else "Desconhecido", - "quantidade": item["quantidade"], - "preco_unitario": item["preco_unitario"] - }) - result.append(pedido) - return result - -def get_todos_pedidos(): - db = get_db() - cursor = db.cursor() - cursor.execute("SELECT * FROM pedidos") - rows = cursor.fetchall() - result = [] - for row in rows: - - pedido = { - "id": row["id"], - "usuario_id": row["usuario_id"], - "status": row["status"], - "total": row["total"], - "criado_em": row["criado_em"], - "itens": [] - } - cursor2 = db.cursor() - cursor2.execute("SELECT * FROM itens_pedido WHERE pedido_id = " + str(row["id"])) - itens = cursor2.fetchall() - for item in itens: - cursor3 = db.cursor() - cursor3.execute("SELECT nome FROM produtos WHERE id = " + str(item["produto_id"])) - prod = cursor3.fetchone() - pedido["itens"].append({ - "produto_id": item["produto_id"], - "produto_nome": prod["nome"] if prod else "Desconhecido", - "quantidade": item["quantidade"], - "preco_unitario": item["preco_unitario"] - }) - result.append(pedido) - return result - -def relatorio_vendas(): - db = get_db() - cursor = db.cursor() - - cursor.execute("SELECT COUNT(*) FROM pedidos") - total_pedidos = cursor.fetchone()[0] - - cursor.execute("SELECT SUM(total) FROM pedidos") - faturamento = cursor.fetchone()[0] - if faturamento is None: - faturamento = 0 - - cursor.execute("SELECT COUNT(*) FROM pedidos WHERE status = 'pendente'") - pendentes = cursor.fetchone()[0] - - cursor.execute("SELECT COUNT(*) FROM pedidos WHERE status = 'aprovado'") - aprovados = cursor.fetchone()[0] - - cursor.execute("SELECT COUNT(*) FROM pedidos WHERE status = 'cancelado'") - cancelados = cursor.fetchone()[0] - - desconto = 0 - if faturamento > 10000: - desconto = faturamento * 0.1 - elif faturamento > 5000: - desconto = faturamento * 0.05 - elif faturamento > 1000: - desconto = faturamento * 0.02 - - return { - "total_pedidos": total_pedidos, - "faturamento_bruto": round(faturamento, 2), - "desconto_aplicavel": round(desconto, 2), - "faturamento_liquido": round(faturamento - desconto, 2), - "pedidos_pendentes": pendentes, - "pedidos_aprovados": aprovados, - "pedidos_cancelados": cancelados, - "ticket_medio": round(faturamento / total_pedidos, 2) if total_pedidos > 0 else 0 - } - -def atualizar_status_pedido(pedido_id, novo_status): - db = get_db() - cursor = db.cursor() - - cursor.execute( - "UPDATE pedidos SET status = '" + novo_status + "' WHERE id = " + str(pedido_id) - ) - db.commit() - return True - -def buscar_produtos(termo, categoria=None, preco_min=None, preco_max=None): - db = get_db() - cursor = db.cursor() - - query = "SELECT * FROM produtos WHERE 1=1" - if termo: - query += " AND (nome LIKE '%" + termo + "%' OR descricao LIKE '%" + termo + "%')" - if categoria: - query += " AND categoria = '" + categoria + "'" - if preco_min: - query += " AND preco >= " + str(preco_min) - if preco_max: - query += " AND preco <= " + str(preco_max) - - cursor.execute(query) - rows = cursor.fetchall() - result = [] - for row in rows: - - result.append({ - "id": row["id"], - "nome": row["nome"], - "descricao": row["descricao"], - "preco": row["preco"], - "estoque": row["estoque"], - "categoria": row["categoria"], - "ativo": row["ativo"], - "criado_em": row["criado_em"] - }) - return result diff --git a/code-smells-project/pyproject.toml b/code-smells-project/pyproject.toml new file mode 100644 index 000000000..dfbece9fb --- /dev/null +++ b/code-smells-project/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "code-smells-project" +version = "2.0.0" +requires-python = ">=3.12" +dependencies = [ + "flask==3.1.1", + "flask-cors==5.0.1", +] + +[dependency-groups] +dev = [ + "pytest==9.1.1", + "ruff==0.16.2", +] + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/code-smells-project/python-development-guidelines.md b/code-smells-project/python-development-guidelines.md new file mode 100644 index 000000000..1444ebb89 --- /dev/null +++ b/code-smells-project/python-development-guidelines.md @@ -0,0 +1,1223 @@ +# Python Development Guidelines + +Practical reference for writing clear, safe, and maintainable Python 3 code. +Examples use the standard library only. Third-party libraries listed in Project Stack are reference metadata. + +## Project Stack + +The following libraries were specified for reference in this project: + +**User-Specified Libraries** (detected from codebase): +- **ORM/Database**: sqlite3 (stdlib) - DB-API 2.0 SQLite driver used with raw SQL - https://docs.python.org/3/library/sqlite3.html +- **Web Framework**: Flask (v3.1.1) - Lightweight WSGI web application framework - https://flask.palletsprojects.com/ +- **CORS**: flask-cors (v5.0.1) - Cross-Origin Resource Sharing for Flask - https://pypi.org/project/flask-cors/ +- **Database**: SQLite (`loja.db`) - Embedded SQL database engine - https://www.sqlite.org/ + +**Auto-Populated Essential Tools**: +- **Testing**: pytest (v9.1.1) - Test runner and assertion framework - https://docs.pytest.org/en/stable/ +- **Formatting**: Ruff (v0.16.2) - Fast Python formatter (`ruff format`) - https://docs.astral.sh/ruff/ +- **Linting**: Ruff (v0.16.2) - Fast Python linter (`ruff check`) - https://docs.astral.sh/ruff/ +- **Logging**: logging (stdlib) - Hierarchical application logging - https://docs.python.org/3/library/logging.html +- **Build Tool**: pip + requirements.txt - Package installer for Python - https://pip.pypa.io/ + +> **Note**: This section lists libraries for quick reference. +> All code examples in this guideline use standard library or language-native features. +> Principles and patterns apply regardless of library choices. + +--- + +## 1. Core Principles + +### 1.1 Philosophy and Style + +- Prefer readability over cleverness (PEP 20 / Zen of Python). +- Format with `ruff format`; lint with `ruff check`. +- Follow PEP 8 naming and layout; let the formatter own whitespace. +- Explicit is better than implicit: prefer clear control flow over magic. + +```bash +python -c "import this" +ruff format . +ruff check . +``` + +### 1.2 Clarity over Brevity + +- Names communicate intent: `user_id` beats `uid` in business logic. +- Self-explanatory code reduces comment volume. +- Optimize only after measuring; clarity ships first. + +| Prefer | Avoid | +|--------|-------| +| `total_price = unit_price * quantity` | `t = u * q` | +| Early returns for guards | Deep nesting | +| Small pure functions | God modules | + +--- + +## 2. Project Initialization + +### 2.1 Creating a New Project + +```bash +mkdir myapp && cd myapp +python3 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +echo "myapp" > README.md +touch pyproject.toml requirements.txt requirements-dev.txt +``` + +Minimal `pyproject.toml`: + +```toml +[project] +name = "myapp" +version = "0.1.0" +requires-python = ">=3.12" + +[tool.ruff] +line-length = 88 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +``` + +### 2.2 Dependency Management + +```bash +python -m pip install flask==3.1.1 +python -m pip freeze > requirements.txt +python -m pip install -r requirements.txt +python -m pip install -r requirements-dev.txt +python -m pip list --outdated +python -m pip uninstall some-package +``` + +Pin production deps; keep tools (`pytest`, `ruff`) in `requirements-dev.txt`. + +--- + +## 3. Project Structure + +Recommended layout for libraries and services: + +```text +myapp/ +├── src/ +│ └── myapp/ +│ ├── __init__.py +│ ├── main.py +│ ├── domain/ +│ ├── services/ +│ └── db/ +├── tests/ +│ ├── unit/ +│ └── integration/ +├── docs/ +├── scripts/ +├── pyproject.toml +├── requirements.txt +├── requirements-dev.txt +└── README.md +``` + +| Path | Role | +|------|------| +| `src/myapp/` | Application package | +| `tests/` | Unit and integration tests | +| `scripts/` | One-off ops / migrations helpers | +| `docs/` | Design notes and API docs | + +Production references: [CPython](https://github.com/python/cpython), [Flask](https://github.com/pallets/flask), [requests](https://github.com/psf/requests). + +--- + +## 4. Container Development (Docker) + +### 4.1 Container Philosophy + +Use Docker so every developer shares the same Python runtime, OS packages, and ports. No local Python version drift. + +### 4.2 Docker File Structure + +```text +Dockerfile +docker-compose.yaml +.dockerignore +``` + +### 4.3 Dockerfile for Development + +Pin the official Alpine image. Keep the container alive with `sleep infinity` for interactive work. + +```dockerfile +FROM python:3.14.7-alpine3.24 + +WORKDIR /app + +RUN apk add --no-cache gcc musl-dev libffi-dev + +COPY requirements.txt requirements-dev.txt ./ +RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt + +COPY . . + +CMD ["sleep", "infinity"] +``` + +### 4.4 Docker Compose + +```yaml +services: + app: + build: . + working_dir: /app + volumes: + - .:/app + ports: + - "5003:5003" + environment: + PYTHONUNBUFFERED: "1" + APP_ENV: development + healthcheck: + test: ["CMD", "python", "-c", "print('ok')"] + interval: 30s + timeout: 5s + retries: 3 +``` + +### 4.5 .dockerignore + +```text +.venv +__pycache__ +*.pyc +.git +.pytest_cache +.ruff_cache +*.db +.env +``` + +### 4.6 Essential Commands + +| Action | Command | +|--------|---------| +| Start | `docker compose up -d --build` | +| Logs | `docker compose logs -f app` | +| Run app | `docker compose exec app python -m myapp.main` | +| Tests | `docker compose exec app pytest -q` | +| Shell | `docker compose exec app sh` | +| Stop | `docker compose down` | + +### 4.7 Best Practices + +- Pin image tags (`python:3.14.7-alpine3.24`), never `latest` in shared envs. +- Mount source for hot reload; install deps inside the image. +- Keep secrets in env files excluded from the image build context. + +--- + +## 5. Naming Conventions + +Follow PEP 8: + +| Kind | Convention | Example | +|------|------------|---------| +| Modules / packages | `snake_case` | `order_service.py` | +| Classes | `PascalCase` | `OrderService` | +| Functions / methods | `snake_case` | `create_order` | +| Variables | `snake_case` | `total_amount` | +| Constants | `UPPER_SNAKE` | `MAX_RETRIES` | +| Private | leading `_` | `_cache` | +| Type aliases | `PascalCase` | `ProductId` | + +```python +MAX_ITEMS = 100 + +class OrderLine: + def __init__(self, product_id: int, quantity: int) -> None: + self.product_id = product_id + self.quantity = quantity + +def calculate_line_total(unit_price: float, quantity: int) -> float: + return unit_price * quantity +``` + +Avoid single-letter names outside short loops (`i`, `j`) or math. + +--- + +## 6. Types and Type System + +Python is dynamically typed with optional gradual typing (PEP 484). Annotate public APIs. + +### 6.1 Type Declaration + +```python +from dataclasses import dataclass +from enum import Enum +from typing import Protocol + + +class OrderStatus(Enum): + PENDING = "pendente" + APPROVED = "aprovado" + CANCELLED = "cancelado" + + +@dataclass(frozen=True) +class Product: + id: int + name: str + price: float + stock: int + + +class Repository(Protocol): + def get(self, product_id: int) -> Product | None: ... +``` + +### 6.2 Type Safety + +```bash +python -m pip install mypy +mypy src +``` + +- Prefer `X | None` over bare optional returns without annotation. +- Use `list[str]`, `dict[str, int]` (Python 3.9+). +- Avoid `Any` except at system boundaries. + +### 6.3 Allocation and Initialization + +```python +from dataclasses import dataclass, field + + +@dataclass +class Cart: + items: list[int] = field(default_factory=list) + + def add(self, product_id: int) -> None: + self.items.append(product_id) +``` + +Never use mutable default arguments (`def f(items=[])`). + +--- + +## 7. Functions and Methods + +### 7.1 Signatures + +```python +def create_product( + name: str, + price: float, + stock: int, + category: str = "geral", +) -> int: + """Persist a product and return its id. + + Raises: + ValueError: if validation fails. + """ + if price < 0 or stock < 0: + raise ValueError("price and stock must be non-negative") + if len(name) < 2: + raise ValueError("name too short") + return _insert_product(name, price, stock, category) +``` + +### 7.2 Returns and Errors — Good vs Bad + +```python +# Good: explicit error, typed return +def get_product(product_id: int) -> dict[str, object]: + product = find_product(product_id) + if product is None: + raise LookupError(f"product {product_id} not found") + return product + + +# Bad: ambiguous None / silent failure +def get_product_bad(product_id: int): + try: + return find_product(product_id) + except Exception: + return None +``` + +### 7.3 Best Practices + +- One responsibility per function. +- Prefer <= 4 parameters; group with a dataclass when more. +- No hidden I/O side effects in pure calculators. +- Document non-obvious preconditions in the docstring. + +--- + +## 8. Error Handling + +### 8.1 Philosophy + +Python uses exceptions. Create domain errors; wrap lower-level failures with context. + +```python +class DomainError(Exception): + """Base application error.""" + + +class InsufficientStockError(DomainError): + def __init__(self, product_id: int, requested: int, available: int) -> None: + super().__init__( + f"product {product_id}: requested {requested}, available {available}" + ) + self.product_id = product_id +``` + +### 8.2 Conventions — Good vs Bad + +```python +# Good: catch specific errors, add context, re-raise or translate +def reserve_stock(product_id: int, quantity: int) -> None: + try: + available = fetch_stock(product_id) + except OSError as exc: + raise DomainError(f"stock lookup failed for {product_id}") from exc + if quantity > available: + raise InsufficientStockError(product_id, quantity, available) + update_stock(product_id, available - quantity) + + +# Bad: swallow everything +def reserve_stock_bad(product_id: int, quantity: int) -> None: + try: + update_stock(product_id, quantity) + except Exception: + pass +``` + +### 8.3 Best Practices + +- Never bare `except:` or `except Exception: pass`. +- Use `raise ... from exc` to preserve cause chains. +- Log at I/O boundaries (HTTP handlers, CLI), not in every helper. +- Map domain errors to HTTP/status codes only at the edge. + +--- + +## 9. Concurrency and Parallelism + +### 9.1 Concurrency Model + +- **threading**: I/O-bound work under the GIL. +- **multiprocessing**: CPU-bound parallelism. +- **asyncio**: cooperative async I/O on one thread. + +```python +import asyncio + + +async def fetch_all(urls: list[str]) -> list[bytes]: + async def one(url: str) -> bytes: + await asyncio.sleep(0.01) + return url.encode() + + return await asyncio.gather(*(one(u) for u in urls)) +``` + +### 9.2 Synchronization + +```python +import threading + +_lock = threading.Lock() +_counter = 0 + + +def increment() -> int: + global _counter + with _lock: + _counter += 1 + return _counter +``` + +Prefer queues (`queue.Queue`, `asyncio.Queue`) over shared mutable state. + +### 9.3 Best Practices + +- Bound lifetimes: cancel tasks, join threads, close pools. +- Always set timeouts on network and lock waits. +- Graceful shutdown via signals / `asyncio.Event`. + +### 9.4 Common Pitfalls + +- Sharing one `sqlite3.Connection` across threads without serialization. +- Fire-and-forget tasks that hide exceptions. +- CPU work blocking the asyncio event loop (use `asyncio.to_thread`). + +--- + +## 10. Interfaces and Abstractions + +### 10.1 Interface Design + +Prefer small Protocols (PEP 544) or ABCs over fat base classes. + +```python +from typing import Protocol + + +class ProductStore(Protocol): + def get(self, product_id: int) -> dict[str, object] | None: ... + def save(self, product: dict[str, object]) -> int: ... +``` + +### 10.2 Implementation + +```python +class MemoryProductStore: + def __init__(self) -> None: + self._items: dict[int, dict[str, object]] = {} + self._seq = 0 + + def get(self, product_id: int) -> dict[str, object] | None: + return self._items.get(product_id) + + def save(self, product: dict[str, object]) -> int: + self._seq += 1 + self._items[self._seq] = product + return self._seq +``` + +### 10.3 Composition + +```python +class OrderService: + def __init__(self, store: ProductStore) -> None: + self._store = store + + def assert_exists(self, product_id: int) -> None: + if self._store.get(product_id) is None: + raise LookupError(product_id) +``` + +Depend on Protocols in constructors; swap implementations in tests. + +--- + +## 11. Unit Tests + +### 11.1 Structure + +```python +# tests/unit/test_pricing.py +import pytest + +from myapp.pricing import apply_discount + + +def test_apply_discount_ten_percent() -> None: + assert apply_discount(100.0, 0.10) == 90.0 + + +def test_apply_discount_rejects_negative() -> None: + with pytest.raises(ValueError, match="negative"): + apply_discount(-1.0, 0.1) +``` + +Naming: `test___`. + +### 11.2 Table-Driven Tests + +```python +@pytest.mark.parametrize( + ("amount", "rate", "expected"), + [ + (100.0, 0.0, 100.0), + (100.0, 0.05, 95.0), + (100.0, 0.10, 90.0), + ], +) +def test_apply_discount_table(amount: float, rate: float, expected: float) -> None: + assert apply_discount(amount, rate) == expected +``` + +### 11.3 Assertions + +- Prefer plain `assert` with pytest rewriting. +- Use `pytest.raises` for exceptions. +- Compare floats with `pytest.approx` when needed. + +### 11.4 Commands + +```bash +pytest +pytest tests/unit/test_pricing.py +pytest -k discount +pytest -vv +pytest --cov=myapp --cov-report=term-missing +pytest -x +``` + +--- + +## 12. Mocks and Testability + +### 12.1 Mock Strategies + +Prefer fakes implementing Protocols. Use `unittest.mock` when fakes are heavy. + +```python +from unittest.mock import Mock + + +def test_order_service_uses_store() -> None: + store = Mock() + store.get.return_value = {"id": 1, "nome": "Mouse"} + service = OrderService(store) + service.assert_exists(1) + store.get.assert_called_once_with(1) +``` + +### 12.2 Dependency Injection + +```python +def build_service(store: ProductStore | None = None) -> OrderService: + return OrderService(store or MemoryProductStore()) +``` + +Inject collaborators; avoid importing globals for I/O inside domain logic. + +### 12.3 Test Doubles + +| Double | Use | +|--------|-----| +| Stub | Fixed return values | +| Fake | In-memory working impl | +| Mock | Interaction assertions | +| Spy | Record calls on real object | + +--- + +## 13. Integration Tests + +### 13.1 Structure and Organization + +```text +tests/ + unit/ + integration/ +``` + +Mark integration tests: + +```python +import pytest + +pytestmark = pytest.mark.integration + + +def test_sqlite_roundtrip(tmp_path) -> None: + import sqlite3 + + db = tmp_path / "test.db" + conn = sqlite3.connect(db) + conn.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)") + conn.execute("INSERT INTO items (name) VALUES (?)", ("x",)) + conn.commit() + row = conn.execute("SELECT name FROM items").fetchone() + assert row[0] == "x" + conn.close() +``` + +### 13.2 Selective Execution + +```bash +pytest -m "not integration" +pytest -m integration +``` + +`pyproject.toml`: + +```toml +[tool.pytest.ini_options] +markers = [ + "integration: tests that touch filesystem or database", +] +``` + +### 13.3 Real Dependencies + +- Prefer temp SQLite files (`tmp_path`) over shared `loja.db`. +- For Postgres/MySQL in CI, use disposable containers and migrate schema each run. +- Never point integration tests at production data. + +--- + +## 14. Load and Stress Tests + +### 14.1 Tools + +- **Locust** — Python-native load scripts. +- **hey** / **vegeta** — HTTP CLI load generators. +- **pytest-benchmark** — microbenchmarks in the test suite. + +### 14.2 Load Benchmarks + +```bash +python -m pip install locust +# locust -f scripts/locustfile.py --headless -u 50 -r 5 -t 1m +hey -n 1000 -c 20 http://127.0.0.1:5003/health +``` + +Define success criteria: p95 latency, error rate < 1%, no connection leaks. + +### 14.3 Concurrency Tests + +```python +from concurrent.futures import ThreadPoolExecutor + + +def test_counter_thread_safe() -> None: + with ThreadPoolExecutor(max_workers=8) as pool: + list(pool.map(lambda _: increment(), range(100))) + assert increment.__wrapped_total__ >= 100 # adapt to your API +``` + +Stress shared resources (DB writes, caches) under parallel callers. + +--- + +## 15. Profiling and Diagnostics + +### 15.1 CPU and Memory Profiling + +```bash +python -m cProfile -o out.prof -m myapp.main +python -m pstats out.prof +python -m pip install memory_profiler +python -m memory_profiler scripts/hot_path.py +``` + +### 15.2 Diagnostic Tools + +| Tool | Purpose | +|------|---------| +| `cProfile` / `pstats` | CPU hotspots | +| `tracemalloc` | Allocation tracing | +| `pdb` / `breakpoint()` | Interactive debug | +| `faulthandler` | Fatal error dumps | + +```python +import tracemalloc + +tracemalloc.start() +# ... workload ... +current, peak = tracemalloc.get_traced_memory() +print(f"current={current} peak={peak}") +tracemalloc.stop() +``` + +### 15.3 Performance Analysis + +1. Reproduce with a realistic dataset. +2. Profile before changing code. +3. Fix the top hotspot; re-measure. +4. Commit the benchmark numbers in the PR description. + +--- + +## 16. Benchmarks + +### 16.1 Writing Benchmarks + +```python +import timeit + + +def bench_join() -> float: + return timeit.timeit(lambda: "".join(["a"] * 1000), number=10_000) +``` + +### 16.2 Sub-benchmarks + +```python +import timeit + + +def run_cases() -> None: + for size in (10, 100, 1000): + stmt = f"sum(range({size}))" + seconds = timeit.timeit(stmt, number=50_000) + print(size, seconds) +``` + +### 16.3 Execution and Analysis + +```bash +python -m timeit "sum(range(1000))" +python -m pytest tests/bench --benchmark-only +python -c "from scripts.bench import run_cases; run_cases()" +``` + +Compare against a baseline branch; reject unexplained regressions > 10% on hot paths. + +--- + +## 17. Optimization + +### 17.1 Principles + +- Measure first (`cProfile`, benchmarks). +- Prefer algorithmic wins over micro-tweaks. +- Document trade-offs when optimizing for speed over clarity. + +### 17.2 Common Optimizations + +```python +# Good: pre-size / single pass +def total(prices: list[float]) -> float: + return sum(prices) + + +# Bad: repeated concatenation in a loop +def bad_join(parts: list[str]) -> str: + out = "" + for part in parts: + out += part + return out + + +# Good +def good_join(parts: list[str]) -> str: + return "".join(parts) +``` + +Use generators for large streams; cache pure expensive calls with `functools.lru_cache` when inputs are hashable. + +### 17.3 Memory Optimization + +- Stream rows (`cursor` iteration) instead of `fetchall()` on huge tables. +- Avoid holding duplicate dict copies of the same entities. +- Prefer `__slots__` or frozen dataclasses for millions of tiny objects. + +### 17.4 Basic Performance + +- Local variable lookups are faster than global; keep hot loops tight. +- Avoid N+1 queries: join or batch-load related rows. +- Do not guess: profile I/O before rewriting CPU paths. + +--- + +## 18. Security + +### 18.1 Essential Practices + +- Never hardcode secrets (`SECRET_KEY`, DB passwords); use env vars. +- Validate and sanitize all external input. +- Use parameterized SQL only (never string-concatenate user data). +- Hash passwords (e.g. stdlib-compatible approaches / established libs); never store plaintext. +- Principle of least privilege on admin endpoints. + +```python +import os + +SECRET_KEY = os.environ["APP_SECRET_KEY"] +``` + +### 18.2 Tools + +```bash +python -m pip install pip-audit +pip-audit +python -m pip install bandit +bandit -r src +``` + +### 18.3 Security at API Boundaries + +```python +# Good: placeholders +conn.execute("SELECT * FROM usuarios WHERE email = ?", (email,)) + +# Bad: SQL injection +conn.execute("SELECT * FROM usuarios WHERE email = '" + email + "'") +``` + +- Do not return password hashes or secret keys in JSON health/debug payloads. +- Disable debug mode in production. +- Protect destructive admin routes with authz. + +--- + +## 19. Code Patterns + +### 19.1 Early Return + +```python +# Good +def create_user(payload: dict[str, str]) -> int: + if not payload.get("email"): + raise ValueError("email required") + if not payload.get("senha"): + raise ValueError("senha required") + return persist_user(payload) + + +# Bad: deep nesting +def create_user_bad(payload: dict[str, str]) -> int | None: + if payload.get("email"): + if payload.get("senha"): + return persist_user(payload) + else: + return None + else: + return None +``` + +### 19.2 Separation of Concerns + +- HTTP layer: parse request, map status codes. +- Service layer: business rules. +- Persistence layer: SQL only. + +### 19.3 DRY + +Extract duplicated validation and row-mapping helpers. Stop abstracting at two similar call sites if a third is speculative. + +### 19.4 Variable Scope + +Declare variables close to use; avoid module-level mutable globals for request state. + +--- + +## 20. Dependency Management + +### 20.1 Principles + +- Standard library first. +- Prefer maintained packages with clear licenses. +- Pin versions in production (`flask==3.1.1`). +- Minimize the dependency graph. + +### 20.2 Commands + +```bash +python -m pip install -r requirements.txt +python -m pip install -r requirements-dev.txt +pip-audit +python -m pip list --outdated +python -m pip cache purge +python -m pip check +``` + +Commit lock-style freezes or pinned requirements used by CI and Docker builds. + +--- + +## 21. Comments and Documentation + +### 21.1 Code Comments + +Comment **why**, not what. + +```python +# Refund window is 7 days per finance policy FN-14 +DEADLINE_DAYS = 7 +``` + +### 21.2 API Documentation + +Use PEP 257 docstrings on public functions and classes. + +```python +def apply_discount(amount: float, rate: float) -> float: + """Return amount after applying a fractional discount rate. + + Args: + amount: Gross value (>= 0). + rate: Fraction in [0, 1]. + + Returns: + Net amount after discount. + """ + if amount < 0 or not 0 <= rate <= 1: + raise ValueError("invalid amount or rate") + return amount * (1 - rate) +``` + +### 21.3 Package Documentation + +- Module docstring at top of each package `__init__.py` describing the public surface. +- Keep README with run, test, and env setup commands. +- Generate API docs with `pydoc` or Sphinx when the surface grows. + +```bash +python -m pydoc myapp.pricing +``` + +--- + +## 22. Database + +### 22.1 Approach + +| Approach | When | +|----------|------| +| Raw SQL + `sqlite3` | Simple apps, full control | +| Query builder | Dynamic filters without full ORM | +| ORM | Complex graphs, migrations, teams already invested | + +This guideline demonstrates **stdlib `sqlite3`** with parameterized SQL. + +### 22.2 Connection and Driver + +```python +import sqlite3 +from collections.abc import Iterator +from contextlib import contextmanager + + +@contextmanager +def connect(db_path: str) -> Iterator[sqlite3.Connection]: + conn = sqlite3.connect(db_path, timeout=5.0) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() +``` + +Parameterized queries (required): + +```python +def get_user_by_email(conn: sqlite3.Connection, email: str) -> sqlite3.Row | None: + cur = conn.execute( + "SELECT id, nome, email, tipo FROM usuarios WHERE email = ?", + (email,), + ) + return cur.fetchone() + + +def create_product( + conn: sqlite3.Connection, + name: str, + price: float, + stock: int, + category: str, +) -> int: + cur = conn.execute( + """ + INSERT INTO produtos (nome, preco, estoque, categoria) + VALUES (?, ?, ?, ?) + """, + (name, price, stock, category), + ) + return int(cur.lastrowid) +``` + +### 22.3 Migrations + +- Version schema changes as numbered SQL files (`migrations/001_init.sql`). +- Apply in order; record applied versions in a `schema_migrations` table. +- Never edit applied migrations; add a new one. + +```bash +python scripts/migrate.py --database loja.db +``` + +### 22.4 Best Practices + +- Always use `?` placeholders; never concatenate SQL. +- Index columns used in WHERE/JOIN. +- One connection per request/thread for SQLite; avoid `check_same_thread=False` without a lock. +- Use transactions for multi-step writes (order + items + stock). +- Fix N+1 with JOINs or `WHERE id IN (...)`. + +--- + +## 23. Logs and Observability + +### 23.1 Log Levels + +| Level | Use | +|-------|-----| +| DEBUG | Detailed diagnostics | +| INFO | Lifecycle / business events | +| WARNING | Recoverable anomalies | +| ERROR | Operation failed | +| CRITICAL | Process cannot continue | + +### 23.2 Structured Logs + +```python +import json +import logging +import sys + + +class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload = { + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + if hasattr(record, "request_id"): + payload["request_id"] = record.request_id + return json.dumps(payload, ensure_ascii=False) + + +def configure_logging(level: int = logging.INFO) -> None: + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(JsonFormatter()) + root = logging.getLogger() + root.handlers.clear() + root.addHandler(handler) + root.setLevel(level) +``` + +### 23.3 Logging Implementation + +```python +import logging + +logger = logging.getLogger(__name__) + + +def create_order(user_id: int, item_count: int) -> None: + logger.info( + "order_created user_id=%s item_count=%s", + user_id, + item_count, + extra={"request_id": "req-123"}, + ) +``` + +- Use `getLogger(__name__)`. +- Prefer `%s` lazy interpolation over f-strings in log calls. +- Never log secrets, tokens, or raw passwords. + +### 23.4 Metrics and Observability + +- Track latency, error rate, and throughput at HTTP/DB boundaries. +- Expose `/health` (liveness) and `/ready` (dependencies OK) without secrets. +- Keep metric label cardinality low (no raw user IDs as label values). + +--- + +## 24. Golden Rules + +1. **Simplicity** — smallest clear design that works. +2. **Explicit errors** — raise typed exceptions; never swallow failures. +3. **Tests** — unit-test domain logic; integration-test SQL and I/O. +4. **Documentation** — README + docstrings on public APIs. +5. **Measured performance** — profile before optimizing. +6. **Secure by default** — parameterized SQL, env-based secrets, least privilege. +7. **Stdlib first** — add dependencies only when they pay rent. + +--- + +## 25. Pre-Commit Checklist + +### Code + +- [ ] `ruff format` applied +- [ ] `ruff check` with no critical findings +- [ ] Application imports/runs without errors + +### Tests + +- [ ] `pytest` passes +- [ ] Coverage >= 70% on critical domain/service code +- [ ] Integration tests run when persistence changed +- [ ] Benchmarks checked if hot paths changed + +### Quality + +- [ ] Errors handled explicitly (no bare except) +- [ ] Connections/files closed (`with` / context managers) +- [ ] No hardcoded secrets +- [ ] `pip-audit` clean for known vulns + +### Documentation + +- [ ] Public functions documented +- [ ] README run/test instructions updated +- [ ] Comments explain non-obvious rationale + +### Docker + +- [ ] Dockerfile pins `python:3.14.7-alpine3.24` (or current agreed tag) +- [ ] `docker compose up` starts cleanly +- [ ] App healthcheck passes inside the container + +--- + +## 26. References + +### Official Documentation + +- [Python 3 Documentation](https://docs.python.org/3/) +- [PEP 8 – Style Guide for Python Code](https://peps.python.org/pep-0008/) +- [PEP 257 – Docstring Conventions](https://peps.python.org/pep-0257/) +- [PEP 484 – Type Hints](https://peps.python.org/pep-0484/) +- [typing – Support for type hints](https://docs.python.org/3/library/typing.html) +- [sqlite3 – DB-API 2.0 for SQLite](https://docs.python.org/3/library/sqlite3.html) +- [logging – Logging facility](https://docs.python.org/3/library/logging.html) +- [Logging HOWTO](https://docs.python.org/3/howto/logging.html) +- [asyncio – Asynchronous I/O](https://docs.python.org/3/library/asyncio.html) +- [unittest.mock](https://docs.python.org/3/library/unittest.mock.html) +- [The Zen of Python (PEP 20)](https://peps.python.org/pep-0020/) + +### Industry Style Guides + +- [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html) +- [Google styleguide repository](https://github.com/google/styleguide) + +### Essential Tools + +- [pip](https://pip.pypa.io/) +- [pytest](https://docs.pytest.org/en/stable/) +- [Ruff](https://docs.astral.sh/ruff/) +- [mypy](https://mypy.readthedocs.io/) +- [pip-audit](https://pypi.org/project/pip-audit/) +- [bandit](https://bandit.readthedocs.io/) + +### Framework / Stack (project reference) + +- [Flask Documentation](https://flask.palletsprojects.com/) +- [Flask 3.1.1 on PyPI](https://pypi.org/project/Flask/3.1.1/) +- [flask-cors](https://pypi.org/project/flask-cors/) +- [SQLite Documentation](https://www.sqlite.org/docs.html) + +### Containers and Ops + +- [Official Python Docker Image](https://hub.docker.com/_/python) +- [Docker Compose overview](https://docs.docker.com/compose/) + +### Production Codebases + +- [CPython](https://github.com/python/cpython) +- [Flask](https://github.com/pallets/flask) +- [requests](https://github.com/psf/requests) + +### Community + +- [Awesome Python](https://github.com/vinta/awesome-python) +- [Real Python](https://realpython.com/) +- [Python Discuss](https://discuss.python.org/) diff --git a/code-smells-project/requirements-dev.txt b/code-smells-project/requirements-dev.txt new file mode 100644 index 000000000..5fdfdcea6 --- /dev/null +++ b/code-smells-project/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest==9.1.1 +ruff==0.16.2 diff --git a/code-smells-project/src/__init__.py b/code-smells-project/src/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code-smells-project/src/app.py b/code-smells-project/src/app.py new file mode 100644 index 000000000..7dbfdc95e --- /dev/null +++ b/code-smells-project/src/app.py @@ -0,0 +1,33 @@ +"""Flask application factory (composition root).""" + +from __future__ import annotations + +import logging + +from flask import Flask +from flask_cors import CORS + +from src.config.settings import Settings, load_settings +from src.db import database +from src.middlewares.error_handler import register_error_handlers +from src.views.routes import register_routes + + +def create_app(settings: Settings | None = None) -> Flask: + settings = settings or load_settings() + + logging.basicConfig( + level=logging.DEBUG if settings.debug else logging.INFO, + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", + ) + + app = Flask(__name__) + app.config["SECRET_KEY"] = settings.secret_key + app.config["DEBUG"] = settings.debug + CORS(app) + + database.init_app(app, settings) + register_error_handlers(app) + register_routes(app) + + return app diff --git a/code-smells-project/src/config/__init__.py b/code-smells-project/src/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code-smells-project/src/config/settings.py b/code-smells-project/src/config/settings.py new file mode 100644 index 000000000..879e77a15 --- /dev/null +++ b/code-smells-project/src/config/settings.py @@ -0,0 +1,54 @@ +"""Application configuration loaded from environment variables.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + + +CATEGORIAS_VALIDAS = ( + "informatica", + "moveis", + "vestuario", + "geral", + "eletronicos", + "livros", +) + +STATUS_PEDIDO_VALIDOS = ( + "pendente", + "aprovado", + "enviado", + "entregue", + "cancelado", +) + +# Discount tiers for sales report (faturamento threshold → rate) +DESCONTO_FAIXAS: tuple[tuple[float, float], ...] = ( + (10_000.0, 0.10), + (5_000.0, 0.05), + (1_000.0, 0.02), +) + + +@dataclass(frozen=True) +class Settings: + secret_key: str + debug: bool + host: str + port: int + db_path: str + ambiente: str + admin_token: str | None + + +def load_settings() -> Settings: + return Settings( + secret_key=os.environ.get("SECRET_KEY", "dev-only-change-me"), + debug=os.environ.get("FLASK_DEBUG", "0") == "1", + host=os.environ.get("HOST", "127.0.0.1"), + port=int(os.environ.get("PORT", "5003")), + db_path=os.environ.get("DB_PATH", "loja.db"), + ambiente=os.environ.get("AMBIENTE", "desenvolvimento"), + admin_token=os.environ.get("ADMIN_TOKEN"), + ) diff --git a/code-smells-project/src/controllers/__init__.py b/code-smells-project/src/controllers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code-smells-project/src/controllers/deps.py b/code-smells-project/src/controllers/deps.py new file mode 100644 index 000000000..40bbcf1fe --- /dev/null +++ b/code-smells-project/src/controllers/deps.py @@ -0,0 +1,30 @@ +"""Helpers to build layered services from the request DB connection.""" + +from __future__ import annotations + +from src.db.database import get_db +from src.models.pedido_model import PedidoModel +from src.models.produto_model import ProdutoModel +from src.models.relatorio_model import RelatorioModel +from src.models.usuario_model import UsuarioModel +from src.services.notificacao_service import NotificacaoService +from src.services.pedido_service import PedidoService +from src.services.produto_service import ProdutoService +from src.services.relatorio_service import RelatorioService +from src.services.usuario_service import UsuarioService + + +def produto_service() -> ProdutoService: + return ProdutoService(ProdutoModel(get_db())) + + +def usuario_service() -> UsuarioService: + return UsuarioService(UsuarioModel(get_db())) + + +def pedido_service() -> PedidoService: + return PedidoService(PedidoModel(get_db()), NotificacaoService()) + + +def relatorio_service() -> RelatorioService: + return RelatorioService(RelatorioModel(get_db())) diff --git a/code-smells-project/src/controllers/health_controller.py b/code-smells-project/src/controllers/health_controller.py new file mode 100644 index 000000000..0a8bff80b --- /dev/null +++ b/code-smells-project/src/controllers/health_controller.py @@ -0,0 +1,62 @@ +"""Health and protected admin controllers.""" + +from __future__ import annotations + +import logging + +from flask import g, jsonify, request + +from src.db.database import get_db, reset_all_data +from src.models.pedido_model import PedidoModel +from src.models.produto_model import ProdutoModel +from src.models.usuario_model import UsuarioModel +from src.services.errors import ForbiddenError + +logger = logging.getLogger(__name__) + + +def health_check(): + db = get_db() + db.execute("SELECT 1") + settings = g._settings + return jsonify( + { + "status": "ok", + "database": "connected", + "counts": { + "produtos": ProdutoModel(db).contar(), + "usuarios": UsuarioModel(db).contar(), + "pedidos": PedidoModel(db).contar(), + }, + "versao": "2.0.0", + "ambiente": settings.ambiente, + } + ), 200 + + +def index(): + return jsonify( + { + "mensagem": "Bem-vindo à API da Loja", + "versao": "2.0.0", + "endpoints": { + "produtos": "/produtos", + "usuarios": "/usuarios", + "pedidos": "/pedidos", + "login": "/login", + "relatorios": "/relatorios/vendas", + "health": "/health", + }, + } + ) + + +def reset_database(): + settings = g._settings + token = request.headers.get("X-Admin-Token", "") + if not settings.admin_token or token != settings.admin_token: + raise ForbiddenError("Admin token inválido ou não configurado") + + reset_all_data(get_db()) + logger.warning("Banco de dados resetado via admin") + return jsonify({"mensagem": "Banco de dados resetado", "sucesso": True}), 200 diff --git a/code-smells-project/src/controllers/pedido_controller.py b/code-smells-project/src/controllers/pedido_controller.py new file mode 100644 index 000000000..04876d75e --- /dev/null +++ b/code-smells-project/src/controllers/pedido_controller.py @@ -0,0 +1,33 @@ +"""Pedido HTTP controllers.""" + +from __future__ import annotations + +from flask import jsonify, request + +from src.controllers.deps import pedido_service + + +def criar_pedido(): + resultado = pedido_service().criar(request.get_json(silent=True)) + return jsonify( + { + "dados": resultado, + "sucesso": True, + "mensagem": "Pedido criado com sucesso", + } + ), 201 + + +def listar_pedidos_usuario(usuario_id: int): + pedidos = pedido_service().listar_por_usuario(usuario_id) + return jsonify({"dados": pedidos, "sucesso": True}), 200 + + +def listar_todos_pedidos(): + pedidos = pedido_service().listar_todos() + return jsonify({"dados": pedidos, "sucesso": True}), 200 + + +def atualizar_status_pedido(pedido_id: int): + pedido_service().atualizar_status(pedido_id, request.get_json(silent=True)) + return jsonify({"sucesso": True, "mensagem": "Status atualizado"}), 200 diff --git a/code-smells-project/src/controllers/produto_controller.py b/code-smells-project/src/controllers/produto_controller.py new file mode 100644 index 000000000..95d2dff6a --- /dev/null +++ b/code-smells-project/src/controllers/produto_controller.py @@ -0,0 +1,47 @@ +"""Produto HTTP controllers — thin request/response adapters.""" + +from __future__ import annotations + +from flask import jsonify, request + +from src.controllers.deps import produto_service + + +def listar_produtos(): + produtos = produto_service().listar() + return jsonify({"dados": produtos, "sucesso": True}), 200 + + +def buscar_produto(id: int): + produto = produto_service().buscar_por_id(id) + return jsonify({"dados": produto, "sucesso": True}), 200 + + +def criar_produto(): + produto_id = produto_service().criar(request.get_json(silent=True)) + return jsonify( + {"dados": {"id": produto_id}, "sucesso": True, "mensagem": "Produto criado"} + ), 201 + + +def atualizar_produto(id: int): + produto_service().atualizar(id, request.get_json(silent=True)) + return jsonify({"sucesso": True, "mensagem": "Produto atualizado"}), 200 + + +def deletar_produto(id: int): + produto_service().deletar(id) + return jsonify({"sucesso": True, "mensagem": "Produto deletado"}), 200 + + +def buscar_produtos(): + termo = request.args.get("q", "") + categoria = request.args.get("categoria") or None + preco_min_raw = request.args.get("preco_min") + preco_max_raw = request.args.get("preco_max") + + preco_min = float(preco_min_raw) if preco_min_raw else None + preco_max = float(preco_max_raw) if preco_max_raw else None + + resultados = produto_service().buscar(termo, categoria, preco_min, preco_max) + return jsonify({"dados": resultados, "total": len(resultados), "sucesso": True}), 200 diff --git a/code-smells-project/src/controllers/relatorio_controller.py b/code-smells-project/src/controllers/relatorio_controller.py new file mode 100644 index 000000000..d36312f8d --- /dev/null +++ b/code-smells-project/src/controllers/relatorio_controller.py @@ -0,0 +1,12 @@ +"""Relatorio HTTP controllers.""" + +from __future__ import annotations + +from flask import jsonify + +from src.controllers.deps import relatorio_service + + +def relatorio_vendas(): + relatorio = relatorio_service().vendas() + return jsonify({"dados": relatorio, "sucesso": True}), 200 diff --git a/code-smells-project/src/controllers/usuario_controller.py b/code-smells-project/src/controllers/usuario_controller.py new file mode 100644 index 000000000..0bd89f2ae --- /dev/null +++ b/code-smells-project/src/controllers/usuario_controller.py @@ -0,0 +1,27 @@ +"""Usuario HTTP controllers.""" + +from __future__ import annotations + +from flask import jsonify, request + +from src.controllers.deps import usuario_service + + +def listar_usuarios(): + usuarios = usuario_service().listar() + return jsonify({"dados": usuarios, "sucesso": True}), 200 + + +def buscar_usuario(id: int): + usuario = usuario_service().buscar_por_id(id) + return jsonify({"dados": usuario, "sucesso": True}), 200 + + +def criar_usuario(): + usuario_id = usuario_service().criar(request.get_json(silent=True)) + return jsonify({"dados": {"id": usuario_id}, "sucesso": True}), 201 + + +def login(): + usuario = usuario_service().login(request.get_json(silent=True)) + return jsonify({"dados": usuario, "sucesso": True, "mensagem": "Login OK"}), 200 diff --git a/code-smells-project/src/db/__init__.py b/code-smells-project/src/db/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code-smells-project/src/db/database.py b/code-smells-project/src/db/database.py new file mode 100644 index 000000000..a2c6b65c7 --- /dev/null +++ b/code-smells-project/src/db/database.py @@ -0,0 +1,165 @@ +"""SQLite connection lifecycle, schema and seed data.""" + +from __future__ import annotations + +import logging +import sqlite3 +from pathlib import Path + +from flask import Flask, g +from werkzeug.security import generate_password_hash + +from src.config.settings import Settings, load_settings + +logger = logging.getLogger(__name__) + + +def get_settings() -> Settings: + return getattr(g, "_settings", None) or load_settings() + + +def get_db() -> sqlite3.Connection: + if "db" not in g: + settings = get_settings() + conn = sqlite3.connect(settings.db_path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + g.db = conn + return g.db # type: ignore[return-value] + + +def close_db(_: BaseException | None = None) -> None: + db = g.pop("db", None) + if db is not None: + db.close() + + +def init_app(app: Flask, settings: Settings) -> None: + app.teardown_appcontext(close_db) + + @app.before_request + def _attach_settings() -> None: + g._settings = settings + + +def _create_schema(conn: sqlite3.Connection) -> None: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS produtos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nome TEXT NOT NULL, + descricao TEXT, + preco REAL NOT NULL, + estoque INTEGER NOT NULL, + categoria TEXT, + ativo INTEGER DEFAULT 1, + criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS usuarios ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nome TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + senha TEXT NOT NULL, + tipo TEXT DEFAULT 'cliente', + criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS pedidos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + usuario_id INTEGER NOT NULL, + status TEXT DEFAULT 'pendente', + total REAL NOT NULL, + criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (usuario_id) REFERENCES usuarios(id) + ); + + CREATE TABLE IF NOT EXISTS itens_pedido ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pedido_id INTEGER NOT NULL, + produto_id INTEGER NOT NULL, + quantidade INTEGER NOT NULL, + preco_unitario REAL NOT NULL, + FOREIGN KEY (pedido_id) REFERENCES pedidos(id), + FOREIGN KEY (produto_id) REFERENCES produtos(id) + ); + """ + ) + + +def _seed_if_empty(conn: sqlite3.Connection) -> None: + count = conn.execute("SELECT COUNT(*) FROM produtos").fetchone()[0] + if count > 0: + return + + produtos = [ + ("Notebook Gamer", "Notebook potente para jogos", 5999.99, 10, "informatica"), + ("Mouse Wireless", "Mouse sem fio ergonômico", 89.90, 50, "informatica"), + ("Teclado Mecânico", "Teclado mecânico RGB", 299.90, 30, "informatica"), + ("Monitor 27''", "Monitor 27 polegadas 144hz", 1899.90, 15, "informatica"), + ("Headset Gamer", "Headset com microfone", 199.90, 25, "informatica"), + ("Cadeira Gamer", "Cadeira ergonômica", 1299.90, 8, "moveis"), + ("Webcam HD", "Webcam 1080p", 249.90, 20, "informatica"), + ("Hub USB", "Hub USB 3.0 7 portas", 79.90, 40, "informatica"), + ("SSD 1TB", "SSD NVMe 1TB", 449.90, 35, "informatica"), + ("Camiseta Dev", "Camiseta estampa código", 59.90, 100, "vestuario"), + ] + conn.executemany( + "INSERT INTO produtos (nome, descricao, preco, estoque, categoria) " + "VALUES (?, ?, ?, ?, ?)", + produtos, + ) + + usuarios = [ + ("Admin", "admin@loja.com", generate_password_hash("admin123"), "admin"), + ("João Silva", "joao@email.com", generate_password_hash("123456"), "cliente"), + ("Maria Santos", "maria@email.com", generate_password_hash("senha123"), "cliente"), + ] + conn.executemany( + "INSERT INTO usuarios (nome, email, senha, tipo) VALUES (?, ?, ?, ?)", + usuarios, + ) + conn.commit() + logger.info("Database seeded with sample products and users") + + +def _migrate_plaintext_passwords(conn: sqlite3.Connection) -> None: + """Hash legacy plaintext passwords from the pre-refactor database.""" + rows = conn.execute("SELECT id, senha FROM usuarios").fetchall() + updated = 0 + for row in rows: + senha = row["senha"] or "" + if senha.startswith(("pbkdf2:", "scrypt:", "argon2:")): + continue + conn.execute( + "UPDATE usuarios SET senha = ? WHERE id = ?", + (generate_password_hash(senha), row["id"]), + ) + updated += 1 + if updated: + conn.commit() + logger.info("Migrated %s plaintext password(s) to hashes", updated) + + +def init_db(settings: Settings | None = None) -> None: + settings = settings or load_settings() + Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(settings.db_path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + try: + _create_schema(conn) + _seed_if_empty(conn) + _migrate_plaintext_passwords(conn) + conn.commit() + finally: + conn.close() + + +def reset_all_data(conn: sqlite3.Connection) -> None: + conn.execute("DELETE FROM itens_pedido") + conn.execute("DELETE FROM pedidos") + conn.execute("DELETE FROM produtos") + conn.execute("DELETE FROM usuarios") + conn.commit() + _seed_if_empty(conn) diff --git a/code-smells-project/src/middlewares/__init__.py b/code-smells-project/src/middlewares/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code-smells-project/src/middlewares/error_handler.py b/code-smells-project/src/middlewares/error_handler.py new file mode 100644 index 000000000..44e689e6e --- /dev/null +++ b/code-smells-project/src/middlewares/error_handler.py @@ -0,0 +1,26 @@ +"""Map domain errors to JSON HTTP responses.""" + +from __future__ import annotations + +import logging + +from flask import Flask, jsonify +from werkzeug.exceptions import HTTPException + +from src.services.errors import DomainError + +logger = logging.getLogger(__name__) + + +def register_error_handlers(app: Flask) -> None: + @app.errorhandler(DomainError) + def handle_domain_error(exc: DomainError): + payload = {"erro": exc.message, "sucesso": False} + return jsonify(payload), exc.status_code + + @app.errorhandler(Exception) + def handle_unexpected(exc: Exception): + if isinstance(exc, HTTPException): + return exc + logger.exception("Unhandled error: %s", exc) + return jsonify({"erro": "Erro interno do servidor", "sucesso": False}), 500 diff --git a/code-smells-project/src/models/__init__.py b/code-smells-project/src/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code-smells-project/src/models/mappers.py b/code-smells-project/src/models/mappers.py new file mode 100644 index 000000000..7351baab2 --- /dev/null +++ b/code-smells-project/src/models/mappers.py @@ -0,0 +1,43 @@ +"""Shared row → dict mappers (never expose password hashes).""" + +from __future__ import annotations + +from sqlite3 import Row +from typing import Any + + +def produto_from_row(row: Row) -> dict[str, Any]: + return { + "id": row["id"], + "nome": row["nome"], + "descricao": row["descricao"], + "preco": row["preco"], + "estoque": row["estoque"], + "categoria": row["categoria"], + "ativo": row["ativo"], + "criado_em": row["criado_em"], + } + + +def usuario_from_row(row: Row, *, include_senha: bool = False) -> dict[str, Any]: + data = { + "id": row["id"], + "nome": row["nome"], + "email": row["email"], + "tipo": row["tipo"], + "criado_em": row["criado_em"], + } + if include_senha: + data["senha"] = row["senha"] + return data + + +def pedido_from_row(row: Row) -> dict[str, Any]: + return { + "id": row["id"], + "usuario_id": row["usuario_id"], + "status": row["status"], + "total": row["total"], + "criado_em": row["criado_em"], + "itens": [], + } diff --git a/code-smells-project/src/models/pedido_model.py b/code-smells-project/src/models/pedido_model.py new file mode 100644 index 000000000..739eee882 --- /dev/null +++ b/code-smells-project/src/models/pedido_model.py @@ -0,0 +1,92 @@ +"""Pedido persistence with JOIN-based item loading (no N+1).""" + +from __future__ import annotations + +import sqlite3 +from typing import Any + +from src.models.mappers import pedido_from_row + + +class PedidoModel: + def __init__(self, db: sqlite3.Connection) -> None: + self._db = db + + def _carregar_pedidos(self, where: str = "", params: tuple[Any, ...] = ()) -> list[dict[str, Any]]: + sql = f"SELECT * FROM pedidos {where} ORDER BY id" + rows = self._db.execute(sql, params).fetchall() + if not rows: + return [] + + pedidos = {row["id"]: pedido_from_row(row) for row in rows} + placeholders = ",".join("?" * len(pedidos)) + itens_sql = f""" + SELECT i.pedido_id, i.produto_id, i.quantidade, i.preco_unitario, + p.nome AS produto_nome + FROM itens_pedido i + LEFT JOIN produtos p ON p.id = i.produto_id + WHERE i.pedido_id IN ({placeholders}) + ORDER BY i.id + """ + item_rows = self._db.execute(itens_sql, tuple(pedidos.keys())).fetchall() + for item in item_rows: + pedidos[item["pedido_id"]]["itens"].append( + { + "produto_id": item["produto_id"], + "produto_nome": item["produto_nome"] or "Desconhecido", + "quantidade": item["quantidade"], + "preco_unitario": item["preco_unitario"], + } + ) + return list(pedidos.values()) + + def listar_todos(self) -> list[dict[str, Any]]: + return self._carregar_pedidos() + + def listar_por_usuario(self, usuario_id: int) -> list[dict[str, Any]]: + return self._carregar_pedidos("WHERE usuario_id = ?", (usuario_id,)) + + def criar(self, usuario_id: int, total: float) -> int: + cursor = self._db.execute( + "INSERT INTO pedidos (usuario_id, status, total) VALUES (?, 'pendente', ?)", + (usuario_id, total), + ) + return int(cursor.lastrowid) + + def adicionar_item( + self, + pedido_id: int, + produto_id: int, + quantidade: int, + preco_unitario: float, + ) -> None: + self._db.execute( + "INSERT INTO itens_pedido " + "(pedido_id, produto_id, quantidade, preco_unitario) VALUES (?, ?, ?, ?)", + (pedido_id, produto_id, quantidade, preco_unitario), + ) + + def decrementar_estoque(self, produto_id: int, quantidade: int) -> None: + self._db.execute( + "UPDATE produtos SET estoque = estoque - ? WHERE id = ?", + (quantidade, produto_id), + ) + + def produto_para_pedido(self, produto_id: int) -> sqlite3.Row | None: + return self._db.execute( + "SELECT id, nome, preco, estoque FROM produtos WHERE id = ?", + (produto_id,), + ).fetchone() + + def atualizar_status(self, pedido_id: int, novo_status: str) -> None: + self._db.execute( + "UPDATE pedidos SET status = ? WHERE id = ?", + (novo_status, pedido_id), + ) + self._db.commit() + + def commit(self) -> None: + self._db.commit() + + def contar(self) -> int: + return int(self._db.execute("SELECT COUNT(*) FROM pedidos").fetchone()[0]) diff --git a/code-smells-project/src/models/produto_model.py b/code-smells-project/src/models/produto_model.py new file mode 100644 index 000000000..edacf602e --- /dev/null +++ b/code-smells-project/src/models/produto_model.py @@ -0,0 +1,91 @@ +"""Produto persistence (parameterized SQL only).""" + +from __future__ import annotations + +import sqlite3 +from typing import Any + +from src.models.mappers import produto_from_row + + +class ProdutoModel: + def __init__(self, db: sqlite3.Connection) -> None: + self._db = db + + def listar_todos(self) -> list[dict[str, Any]]: + rows = self._db.execute("SELECT * FROM produtos ORDER BY id").fetchall() + return [produto_from_row(row) for row in rows] + + def buscar_por_id(self, produto_id: int) -> dict[str, Any] | None: + row = self._db.execute( + "SELECT * FROM produtos WHERE id = ?", + (produto_id,), + ).fetchone() + return produto_from_row(row) if row else None + + def criar( + self, + nome: str, + descricao: str, + preco: float, + estoque: int, + categoria: str, + ) -> int: + cursor = self._db.execute( + "INSERT INTO produtos (nome, descricao, preco, estoque, categoria) " + "VALUES (?, ?, ?, ?, ?)", + (nome, descricao, preco, estoque, categoria), + ) + self._db.commit() + return int(cursor.lastrowid) + + def atualizar( + self, + produto_id: int, + nome: str, + descricao: str, + preco: float, + estoque: int, + categoria: str, + ) -> None: + self._db.execute( + "UPDATE produtos SET nome = ?, descricao = ?, preco = ?, " + "estoque = ?, categoria = ? WHERE id = ?", + (nome, descricao, preco, estoque, categoria, produto_id), + ) + self._db.commit() + + def deletar(self, produto_id: int) -> None: + self._db.execute("DELETE FROM produtos WHERE id = ?", (produto_id,)) + self._db.commit() + + def buscar( + self, + termo: str = "", + categoria: str | None = None, + preco_min: float | None = None, + preco_max: float | None = None, + ) -> list[dict[str, Any]]: + clauses = ["1=1"] + params: list[Any] = [] + + if termo: + clauses.append("(nome LIKE ? OR descricao LIKE ?)") + like = f"%{termo}%" + params.extend([like, like]) + if categoria: + clauses.append("categoria = ?") + params.append(categoria) + if preco_min is not None: + clauses.append("preco >= ?") + params.append(preco_min) + if preco_max is not None: + clauses.append("preco <= ?") + params.append(preco_max) + + sql = f"SELECT * FROM produtos WHERE {' AND '.join(clauses)} ORDER BY id" + rows = self._db.execute(sql, params).fetchall() + return [produto_from_row(row) for row in rows] + + def contar(self) -> int: + return int(self._db.execute("SELECT COUNT(*) FROM produtos").fetchone()[0]) diff --git a/code-smells-project/src/models/relatorio_model.py b/code-smells-project/src/models/relatorio_model.py new file mode 100644 index 000000000..c9497e14b --- /dev/null +++ b/code-smells-project/src/models/relatorio_model.py @@ -0,0 +1,34 @@ +"""Sales report queries.""" + +from __future__ import annotations + +import sqlite3 +from typing import Any + + +class RelatorioModel: + def __init__(self, db: sqlite3.Connection) -> None: + self._db = db + + def agregados_vendas(self) -> dict[str, Any]: + total_pedidos = self._db.execute("SELECT COUNT(*) FROM pedidos").fetchone()[0] + faturamento = self._db.execute("SELECT SUM(total) FROM pedidos").fetchone()[0] + pendentes = self._db.execute( + "SELECT COUNT(*) FROM pedidos WHERE status = ?", + ("pendente",), + ).fetchone()[0] + aprovados = self._db.execute( + "SELECT COUNT(*) FROM pedidos WHERE status = ?", + ("aprovado",), + ).fetchone()[0] + cancelados = self._db.execute( + "SELECT COUNT(*) FROM pedidos WHERE status = ?", + ("cancelado",), + ).fetchone()[0] + return { + "total_pedidos": total_pedidos, + "faturamento": float(faturamento or 0), + "pendentes": pendentes, + "aprovados": aprovados, + "cancelados": cancelados, + } diff --git a/code-smells-project/src/models/usuario_model.py b/code-smells-project/src/models/usuario_model.py new file mode 100644 index 000000000..8ab8915a2 --- /dev/null +++ b/code-smells-project/src/models/usuario_model.py @@ -0,0 +1,51 @@ +"""Usuario persistence (parameterized SQL only).""" + +from __future__ import annotations + +import sqlite3 +from typing import Any + +from src.models.mappers import usuario_from_row + + +class UsuarioModel: + def __init__(self, db: sqlite3.Connection) -> None: + self._db = db + + def listar_todos(self) -> list[dict[str, Any]]: + rows = self._db.execute("SELECT * FROM usuarios ORDER BY id").fetchall() + return [usuario_from_row(row) for row in rows] + + def buscar_por_id(self, usuario_id: int) -> dict[str, Any] | None: + row = self._db.execute( + "SELECT * FROM usuarios WHERE id = ?", + (usuario_id,), + ).fetchone() + return usuario_from_row(row) if row else None + + def buscar_por_email(self, email: str) -> dict[str, Any] | None: + row = self._db.execute( + "SELECT * FROM usuarios WHERE email = ?", + (email,), + ).fetchone() + return usuario_from_row(row, include_senha=True) if row else None + + def criar(self, nome: str, email: str, senha_hash: str, tipo: str = "cliente") -> int: + cursor = self._db.execute( + "INSERT INTO usuarios (nome, email, senha, tipo) VALUES (?, ?, ?, ?)", + (nome, email, senha_hash, tipo), + ) + self._db.commit() + return int(cursor.lastrowid) + + def contar(self) -> int: + return int(self._db.execute("SELECT COUNT(*) FROM usuarios").fetchone()[0]) + + @staticmethod + def para_login(row_dict: dict[str, Any]) -> dict[str, Any]: + return { + "id": row_dict["id"], + "nome": row_dict["nome"], + "email": row_dict["email"], + "tipo": row_dict["tipo"], + } diff --git a/code-smells-project/src/services/__init__.py b/code-smells-project/src/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code-smells-project/src/services/errors.py b/code-smells-project/src/services/errors.py new file mode 100644 index 000000000..06c9a5851 --- /dev/null +++ b/code-smells-project/src/services/errors.py @@ -0,0 +1,25 @@ +"""Domain exceptions mapped to HTTP by controllers/middleware.""" + +from __future__ import annotations + + +class DomainError(Exception): + def __init__(self, message: str, status_code: int = 400) -> None: + super().__init__(message) + self.message = message + self.status_code = status_code + + +class NotFoundError(DomainError): + def __init__(self, message: str) -> None: + super().__init__(message, status_code=404) + + +class UnauthorizedError(DomainError): + def __init__(self, message: str) -> None: + super().__init__(message, status_code=401) + + +class ForbiddenError(DomainError): + def __init__(self, message: str) -> None: + super().__init__(message, status_code=403) diff --git a/code-smells-project/src/services/notificacao_service.py b/code-smells-project/src/services/notificacao_service.py new file mode 100644 index 000000000..a7e7c0811 --- /dev/null +++ b/code-smells-project/src/services/notificacao_service.py @@ -0,0 +1,26 @@ +"""Notification side effects (logging instead of print).""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + + +class NotificacaoService: + def pedido_criado(self, pedido_id: int, usuario_id: int) -> None: + logger.info("ENVIANDO EMAIL: Pedido %s criado para usuario %s", pedido_id, usuario_id) + logger.info("ENVIANDO SMS: Seu pedido foi recebido!") + logger.info("ENVIANDO PUSH: Novo pedido recebido pelo sistema") + + def status_atualizado(self, pedido_id: int, novo_status: str) -> None: + if novo_status == "aprovado": + logger.info( + "NOTIFICAÇÃO: Pedido %s foi aprovado! Preparar envio.", + pedido_id, + ) + elif novo_status == "cancelado": + logger.info( + "NOTIFICAÇÃO: Pedido %s cancelado. Devolver estoque.", + pedido_id, + ) diff --git a/code-smells-project/src/services/pedido_service.py b/code-smells-project/src/services/pedido_service.py new file mode 100644 index 000000000..5c3e938fd --- /dev/null +++ b/code-smells-project/src/services/pedido_service.py @@ -0,0 +1,90 @@ +"""Pedido business rules: stock, totals, status transitions.""" + +from __future__ import annotations + +import logging +from typing import Any + +from src.config.settings import STATUS_PEDIDO_VALIDOS +from src.models.pedido_model import PedidoModel +from src.services.errors import DomainError +from src.services.notificacao_service import NotificacaoService + +logger = logging.getLogger(__name__) + + +class PedidoService: + def __init__( + self, + model: PedidoModel, + notificacoes: NotificacaoService | None = None, + ) -> None: + self._model = model + self._notificacoes = notificacoes or NotificacaoService() + + def listar_todos(self) -> list[dict[str, Any]]: + return self._model.listar_todos() + + def listar_por_usuario(self, usuario_id: int) -> list[dict[str, Any]]: + return self._model.listar_por_usuario(usuario_id) + + def criar(self, dados: dict[str, Any] | None) -> dict[str, Any]: + if not dados: + raise DomainError("Dados inválidos") + + usuario_id = dados.get("usuario_id") + itens = dados.get("itens") or [] + + if not usuario_id: + raise DomainError("Usuario ID é obrigatório") + if not itens: + raise DomainError("Pedido deve ter pelo menos 1 item") + + try: + usuario_id = int(usuario_id) + except (TypeError, ValueError) as exc: + raise DomainError("Usuario ID inválido") from exc + + linhas: list[tuple[int, int, float]] = [] + total = 0.0 + + for item in itens: + try: + produto_id = int(item["produto_id"]) + quantidade = int(item["quantidade"]) + except (KeyError, TypeError, ValueError) as exc: + raise DomainError("Item de pedido inválido") from exc + + if quantidade <= 0: + raise DomainError("Quantidade deve ser positiva") + + produto = self._model.produto_para_pedido(produto_id) + if produto is None: + raise DomainError(f"Produto {produto_id} não encontrado") + if produto["estoque"] < quantidade: + raise DomainError(f"Estoque insuficiente para {produto['nome']}") + + preco = float(produto["preco"]) + total += preco * quantidade + linhas.append((produto_id, quantidade, preco)) + + pedido_id = self._model.criar(usuario_id, total) + for produto_id, quantidade, preco in linhas: + self._model.adicionar_item(pedido_id, produto_id, quantidade, preco) + self._model.decrementar_estoque(produto_id, quantidade) + self._model.commit() + + resultado = {"pedido_id": pedido_id, "total": total} + self._notificacoes.pedido_criado(pedido_id, usuario_id) + return resultado + + def atualizar_status(self, pedido_id: int, dados: dict[str, Any] | None) -> None: + if not dados: + raise DomainError("Dados inválidos") + + novo_status = str(dados.get("status", "")) + if novo_status not in STATUS_PEDIDO_VALIDOS: + raise DomainError("Status inválido") + + self._model.atualizar_status(pedido_id, novo_status) + self._notificacoes.status_atualizado(pedido_id, novo_status) diff --git a/code-smells-project/src/services/produto_service.py b/code-smells-project/src/services/produto_service.py new file mode 100644 index 000000000..67b009f66 --- /dev/null +++ b/code-smells-project/src/services/produto_service.py @@ -0,0 +1,92 @@ +"""Produto business rules and validation.""" + +from __future__ import annotations + +import logging +from typing import Any + +from src.config.settings import CATEGORIAS_VALIDAS +from src.models.produto_model import ProdutoModel +from src.services.errors import DomainError, NotFoundError + +logger = logging.getLogger(__name__) + + +class ProdutoService: + def __init__(self, model: ProdutoModel) -> None: + self._model = model + + def listar(self) -> list[dict[str, Any]]: + produtos = self._model.listar_todos() + logger.info("Listando %s produtos", len(produtos)) + return produtos + + def buscar_por_id(self, produto_id: int) -> dict[str, Any]: + produto = self._model.buscar_por_id(produto_id) + if not produto: + raise NotFoundError("Produto não encontrado") + return produto + + def criar(self, dados: dict[str, Any]) -> int: + payload = self._validar_payload(dados) + produto_id = self._model.criar(**payload) + logger.info("Produto criado com ID: %s", produto_id) + return produto_id + + def atualizar(self, produto_id: int, dados: dict[str, Any]) -> None: + if not self._model.buscar_por_id(produto_id): + raise NotFoundError("Produto não encontrado") + payload = self._validar_payload(dados) + self._model.atualizar(produto_id, **payload) + + def deletar(self, produto_id: int) -> None: + if not self._model.buscar_por_id(produto_id): + raise NotFoundError("Produto não encontrado") + self._model.deletar(produto_id) + logger.info("Produto %s deletado", produto_id) + + def buscar( + self, + termo: str = "", + categoria: str | None = None, + preco_min: float | None = None, + preco_max: float | None = None, + ) -> list[dict[str, Any]]: + return self._model.buscar(termo, categoria, preco_min, preco_max) + + def _validar_payload(self, dados: dict[str, Any] | None) -> dict[str, Any]: + if not dados: + raise DomainError("Dados inválidos") + for campo in ("nome", "preco", "estoque"): + if campo not in dados: + label = {"nome": "Nome", "preco": "Preço", "estoque": "Estoque"}[campo] + raise DomainError(f"{label} é obrigatório") + + nome = str(dados["nome"]) + descricao = str(dados.get("descricao", "")) + try: + preco = float(dados["preco"]) + estoque = int(dados["estoque"]) + except (TypeError, ValueError) as exc: + raise DomainError("Preço ou estoque inválidos") from exc + + categoria = str(dados.get("categoria", "geral")) + + if preco < 0: + raise DomainError("Preço não pode ser negativo") + if estoque < 0: + raise DomainError("Estoque não pode ser negativo") + if len(nome) < 2: + raise DomainError("Nome muito curto") + if len(nome) > 200: + raise DomainError("Nome muito longo") + if categoria not in CATEGORIAS_VALIDAS: + raise DomainError(f"Categoria inválida. Válidas: {list(CATEGORIAS_VALIDAS)}") + + return { + "nome": nome, + "descricao": descricao, + "preco": preco, + "estoque": estoque, + "categoria": categoria, + } diff --git a/code-smells-project/src/services/relatorio_service.py b/code-smells-project/src/services/relatorio_service.py new file mode 100644 index 000000000..908b04a8a --- /dev/null +++ b/code-smells-project/src/services/relatorio_service.py @@ -0,0 +1,37 @@ +"""Sales report calculations.""" + +from __future__ import annotations + +from typing import Any + +from src.config.settings import DESCONTO_FAIXAS +from src.models.relatorio_model import RelatorioModel + + +class RelatorioService: + def __init__(self, model: RelatorioModel) -> None: + self._model = model + + def vendas(self) -> dict[str, Any]: + dados = self._model.agregados_vendas() + faturamento = dados["faturamento"] + total_pedidos = dados["total_pedidos"] + desconto = self._calcular_desconto(faturamento) + + return { + "total_pedidos": total_pedidos, + "faturamento_bruto": round(faturamento, 2), + "desconto_aplicavel": round(desconto, 2), + "faturamento_liquido": round(faturamento - desconto, 2), + "pedidos_pendentes": dados["pendentes"], + "pedidos_aprovados": dados["aprovados"], + "pedidos_cancelados": dados["cancelados"], + "ticket_medio": round(faturamento / total_pedidos, 2) if total_pedidos > 0 else 0, + } + + @staticmethod + def _calcular_desconto(faturamento: float) -> float: + for limite, taxa in DESCONTO_FAIXAS: + if faturamento > limite: + return faturamento * taxa + return 0.0 diff --git a/code-smells-project/src/services/usuario_service.py b/code-smells-project/src/services/usuario_service.py new file mode 100644 index 000000000..603464f12 --- /dev/null +++ b/code-smells-project/src/services/usuario_service.py @@ -0,0 +1,60 @@ +"""Usuario and authentication business rules.""" + +from __future__ import annotations + +import logging +from typing import Any + +from werkzeug.security import check_password_hash, generate_password_hash + +from src.models.usuario_model import UsuarioModel +from src.services.errors import DomainError, NotFoundError, UnauthorizedError + +logger = logging.getLogger(__name__) + + +class UsuarioService: + def __init__(self, model: UsuarioModel) -> None: + self._model = model + + def listar(self) -> list[dict[str, Any]]: + return self._model.listar_todos() + + def buscar_por_id(self, usuario_id: int) -> dict[str, Any]: + usuario = self._model.buscar_por_id(usuario_id) + if not usuario: + raise NotFoundError("Usuário não encontrado") + return usuario + + def criar(self, dados: dict[str, Any] | None) -> int: + if not dados: + raise DomainError("Dados inválidos") + + nome = str(dados.get("nome", "")).strip() + email = str(dados.get("email", "")).strip() + senha = str(dados.get("senha", "")) + + if not nome or not email or not senha: + raise DomainError("Nome, email e senha são obrigatórios") + + usuario_id = self._model.criar(nome, email, generate_password_hash(senha)) + logger.info("Usuário criado: %s", email) + return usuario_id + + def login(self, dados: dict[str, Any] | None) -> dict[str, Any]: + if not dados: + raise DomainError("Dados inválidos") + + email = str(dados.get("email", "")).strip() + senha = str(dados.get("senha", "")) + + if not email or not senha: + raise DomainError("Email e senha são obrigatórios") + + usuario = self._model.buscar_por_email(email) + if not usuario or not check_password_hash(usuario["senha"], senha): + logger.info("Login falhou: %s", email) + raise UnauthorizedError("Email ou senha inválidos") + + logger.info("Login bem-sucedido: %s", email) + return UsuarioModel.para_login(usuario) diff --git a/code-smells-project/src/views/__init__.py b/code-smells-project/src/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code-smells-project/src/views/routes.py b/code-smells-project/src/views/routes.py new file mode 100644 index 000000000..6ae8da9fd --- /dev/null +++ b/code-smells-project/src/views/routes.py @@ -0,0 +1,117 @@ +"""View layer: HTTP route registration (URL → controller).""" + +from __future__ import annotations + +from flask import Flask + +from src.controllers import ( + health_controller, + pedido_controller, + produto_controller, + relatorio_controller, + usuario_controller, +) + + +def register_routes(app: Flask) -> None: + app.add_url_rule("/", "index", health_controller.index, methods=["GET"]) + app.add_url_rule( + "/health", "health_check", health_controller.health_check, methods=["GET"] + ) + + app.add_url_rule( + "/produtos", + "listar_produtos", + produto_controller.listar_produtos, + methods=["GET"], + ) + app.add_url_rule( + "/produtos/busca", + "buscar_produtos", + produto_controller.buscar_produtos, + methods=["GET"], + ) + app.add_url_rule( + "/produtos/", + "buscar_produto", + produto_controller.buscar_produto, + methods=["GET"], + ) + app.add_url_rule( + "/produtos", + "criar_produto", + produto_controller.criar_produto, + methods=["POST"], + ) + app.add_url_rule( + "/produtos/", + "atualizar_produto", + produto_controller.atualizar_produto, + methods=["PUT"], + ) + app.add_url_rule( + "/produtos/", + "deletar_produto", + produto_controller.deletar_produto, + methods=["DELETE"], + ) + + app.add_url_rule( + "/usuarios", + "listar_usuarios", + usuario_controller.listar_usuarios, + methods=["GET"], + ) + app.add_url_rule( + "/usuarios/", + "buscar_usuario", + usuario_controller.buscar_usuario, + methods=["GET"], + ) + app.add_url_rule( + "/usuarios", + "criar_usuario", + usuario_controller.criar_usuario, + methods=["POST"], + ) + app.add_url_rule("/login", "login", usuario_controller.login, methods=["POST"]) + + app.add_url_rule( + "/pedidos", + "criar_pedido", + pedido_controller.criar_pedido, + methods=["POST"], + ) + app.add_url_rule( + "/pedidos", + "listar_todos_pedidos", + pedido_controller.listar_todos_pedidos, + methods=["GET"], + ) + app.add_url_rule( + "/pedidos/usuario/", + "listar_pedidos_usuario", + pedido_controller.listar_pedidos_usuario, + methods=["GET"], + ) + app.add_url_rule( + "/pedidos//status", + "atualizar_status_pedido", + pedido_controller.atualizar_status_pedido, + methods=["PUT"], + ) + + app.add_url_rule( + "/relatorios/vendas", + "relatorio_vendas", + relatorio_controller.relatorio_vendas, + methods=["GET"], + ) + + # Protected admin — arbitrary SQL endpoint removed + app.add_url_rule( + "/admin/reset-db", + "reset_database", + health_controller.reset_database, + methods=["POST"], + ) diff --git a/code-smells-project/tests/__init__.py b/code-smells-project/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code-smells-project/tests/conftest.py b/code-smells-project/tests/conftest.py new file mode 100644 index 000000000..6e4d99435 --- /dev/null +++ b/code-smells-project/tests/conftest.py @@ -0,0 +1,35 @@ +"""Shared pytest fixtures.""" + +from __future__ import annotations + +import pytest + +from src.app import create_app +from src.config.settings import Settings +from src.db.database import init_db + + +@pytest.fixture() +def settings(tmp_path) -> Settings: + return Settings( + secret_key="test-secret", + debug=False, + host="127.0.0.1", + port=5003, + db_path=str(tmp_path / "test.db"), + ambiente="teste", + admin_token="test-admin-token", + ) + + +@pytest.fixture() +def app(settings: Settings): + init_db(settings) + application = create_app(settings) + application.config["TESTING"] = True + return application + + +@pytest.fixture() +def client(app): + return app.test_client() diff --git a/code-smells-project/tests/integration/test_api.py b/code-smells-project/tests/integration/test_api.py new file mode 100644 index 000000000..f6ef2aa6a --- /dev/null +++ b/code-smells-project/tests/integration/test_api.py @@ -0,0 +1,76 @@ +"""Integration tests for core API behavior after MVC refactor.""" + +from __future__ import annotations + + +def test_health_does_not_leak_secrets(client): + response = client.get("/health") + assert response.status_code == 200 + data = response.get_json() + assert data["status"] == "ok" + assert "secret_key" not in data + assert "debug" not in data + assert "db_path" not in data + assert data["ambiente"] == "teste" + + +def test_login_with_seed_user(client): + response = client.post( + "/login", + json={"email": "joao@email.com", "senha": "123456"}, + ) + assert response.status_code == 200 + body = response.get_json() + assert body["sucesso"] is True + assert body["dados"]["email"] == "joao@email.com" + assert "senha" not in body["dados"] + + +def test_usuarios_do_not_return_password(client): + response = client.get("/usuarios") + assert response.status_code == 200 + for usuario in response.get_json()["dados"]: + assert "senha" not in usuario + + +def test_sql_injection_in_produto_id_is_safe(client): + response = client.get("/produtos/1 OR 1=1") + assert response.status_code == 404 + + +def test_sql_injection_in_busca_is_safe(client): + response = client.get("/produtos/busca", query_string={"q": "'; DROP TABLE produtos;--"}) + assert response.status_code == 200 + assert response.get_json()["sucesso"] is True + # table still works + assert client.get("/produtos").status_code == 200 + + +def test_criar_pedido_and_list(client): + produtos = client.get("/produtos").get_json()["dados"] + produto_id = produtos[0]["id"] + + created = client.post( + "/pedidos", + json={"usuario_id": 2, "itens": [{"produto_id": produto_id, "quantidade": 1}]}, + ) + assert created.status_code == 201 + pedido_id = created.get_json()["dados"]["pedido_id"] + + listed = client.get("/pedidos/usuario/2") + assert listed.status_code == 200 + ids = [p["id"] for p in listed.get_json()["dados"]] + assert pedido_id in ids + + +def test_admin_query_removed(client): + response = client.post("/admin/query", json={"sql": "SELECT 1"}) + assert response.status_code == 404 + + +def test_admin_reset_requires_token(client): + denied = client.post("/admin/reset-db") + assert denied.status_code == 403 + + ok = client.post("/admin/reset-db", headers={"X-Admin-Token": "test-admin-token"}) + assert ok.status_code == 200 diff --git a/code-smells-project/tests/unit/test_relatorio_service.py b/code-smells-project/tests/unit/test_relatorio_service.py new file mode 100644 index 000000000..335226811 --- /dev/null +++ b/code-smells-project/tests/unit/test_relatorio_service.py @@ -0,0 +1,12 @@ +"""Unit tests for report discount rules.""" + +from __future__ import annotations + +from src.services.relatorio_service import RelatorioService + + +def test_desconto_faixas(): + assert RelatorioService._calcular_desconto(500) == 0.0 + assert RelatorioService._calcular_desconto(1500) == 1500 * 0.02 + assert RelatorioService._calcular_desconto(6000) == 6000 * 0.05 + assert RelatorioService._calcular_desconto(12000) == 12000 * 0.10 diff --git a/code-smells-project/uv.lock b/code-smells-project/uv.lock new file mode 100644 index 000000000..8d34732c2 --- /dev/null +++ b/code-smells-project/uv.lock @@ -0,0 +1,263 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "code-smells-project" +version = "2.0.0" +source = { virtual = "." } +dependencies = [ + { name = "flask" }, + { name = "flask-cors" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "flask", specifier = "==3.1.1" }, + { name = "flask-cors", specifier = "==5.0.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = "==9.1.1" }, + { name = "ruff", specifier = "==0.16.2" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "flask" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/de/e47735752347f4128bcf354e0da07ef311a78244eba9e3dc1d4a5ab21a98/flask-3.1.1.tar.gz", hash = "sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e", size = 753440, upload-time = "2025-05-13T15:01:17.447Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/68/9d4508e893976286d2ead7f8f571314af6c2037af34853a30fd769c02e9d/flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", size = 103305, upload-time = "2025-05-13T15:01:15.591Z" }, +] + +[[package]] +name = "flask-cors" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/d8/667bd90d1ee41c96e938bafe81052494e70b7abd9498c4a0215c103b9667/flask_cors-5.0.1.tar.gz", hash = "sha256:6ccb38d16d6b72bbc156c1c3f192bc435bfcc3c2bc864b2df1eb9b2d97b2403c", size = 11643, upload-time = "2025-02-24T03:57:02.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/61/4aea5fb55be1b6f95e604627dc6c50c47d693e39cab2ac086ee0155a0abd/flask_cors-5.0.1-py3-none-any.whl", hash = "sha256:fa5cb364ead54bbf401a26dbf03030c6b18fb2fcaf70408096a572b409586b0c", size = 11296, upload-time = "2025-02-24T03:57:00.621Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +]