commit 6637fb8ff432372cd2cf5765c9632fe033ec0192 Author: Кедрин Виктор Date: Thu Nov 12 13:55:02 2026 +0800 CI demo: metrics + Gitea Actions diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..6197071 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install -r requirements.txt + - run: ruff check . + + test: + needs: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install -r requirements.txt + - run: pytest -q --cov=app --cov-branch --cov-fail-under=60 + + metrics: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install -r requirements.txt + - run: radon cc app -s -a + - run: xenon --max-absolute C app/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e18afb --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.coverage +coverage.xml +.venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbcb490 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# ci-demo (Gitea) — учебный стенд CI к лекции 9 + +Мини-проект к лекции «Метрики сложности и CI» (неделя 9, `../09l_л_Метрики_сложности.md`). +Домен — «калькулятор скидок», чтобы студенты узнавали код из практики. + +[![CI](https://gitea.example.com/OWNER/ci-demo/actions/workflows/ci.yml/badge.svg?branch=main)](https://gitea.example.com/OWNER/ci-demo/actions) + +> Замените `gitea.example.com` и `OWNER` на ваш сервер и логин. + +## Что внутри + +``` +app/ + discounts.py # стратегии скидок: regular / vip / employee + service.py # OrderService — контекст, делегирует скидке + legacy_report.py # функция ранга C — для метрического гейта +tests/ + test_discounts.py # 5 тестов, включая проверку версии Python для матрицы + test_legacy_report.py +.gitea/workflows/ + ci.yml # lint → test (coverage) → metrics (radon + xenon) +workflows_заготовки/ + ci-metrics-strict.yml # тот же пайплайн, но xenon --max-absolute B (красный) + ci-matrix.yml # матрица 3.11/3.12/3.13, fail-fast: false + ci-artifacts.yml # артефакт coverage.xml + bad.yml # «плохой» workflow с || true +``` + +## Локальный запуск + +```powershell +python -m venv .venv +.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +pytest -q --cov=app --cov-branch --cov-fail-under=60 +ruff check . +radon cc app -s -a +xenon --max-absolute C app/ # зелёный: legacy_report.py ранга C +xenon --max-absolute B app/ # красный: гейт не пропускает C +``` + +## Требования к серверу Gitea + +Gitea 1.19+ с включёнными Actions (`app.ini`: `[actions] ENABLED = true`); act_runner онлайн с меткой `ubuntu-latest`. + +## Как развернуть стенд + +1. Создайте пустой репозиторий `ci-demo` на сервере Gitea. +2. Содержимое этой папки — корень репозитория: + +```powershell +git init +git add . +git commit -m "Стартовый каркас демо" +git branch -M main +git remote add origin https://gitea.example.com/OWNER/ci-demo.git +git push -u origin main +``` + +3. Замените `gitea.example.com` и `OWNER` в бейдже README. +4. Дождитесь первого прогона CI (act_runner прогреет кеш pip). +5. Настройте защиту ветки `main`: обязательные проверки `lint`, `test`, + `metrics` (Status Check Contexts), запрет force-push. + +## Отличия Gitea-варианта + +- workflow лежит в `.gitea/workflows/ci.yml`; +- прогоны выполняет act_runner; +- кеш и артефакты зависят от версии Gitea: заготовки это учитывают. + +## Метрический гейт + +`app/legacy_report.py` намеренно содержит функцию ранга C (V(G) = 17). +Основной workflow использует `xenon --max-absolute C` и остаётся зелёным. +Строгая заготовка `ci-metrics-strict.yml` поднимает планку до B — сборка +красная. Это и есть демонстрация «метрика работает, когда её нельзя +игнорировать»: порог в CI делает рост сложности блокирующим. + +## Важно + +Стенд учебный: секреты, деплой и реальные данные здесь не используются. +«Плохой» workflow из `workflows_заготовки/bad.yml` запускать только в этом +демо-репозитории. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..14c2c5b --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Пакет приложения ci-demo (Gitea edition).""" diff --git a/app/discounts.py b/app/discounts.py new file mode 100644 index 0000000..71fe29d --- /dev/null +++ b/app/discounts.py @@ -0,0 +1,35 @@ +"""Скидки демо-проекта ci-demo (СТРПО, неделя 9, Gitea edition).""" +from abc import ABC, abstractmethod + + +class Discount(ABC): + @abstractmethod + def apply(self, total: float) -> float: + """Вернуть сумму с учётом скидки.""" + + +class NoDiscount(Discount): + def apply(self, total: float) -> float: + return total + + +class VIPDiscount(Discount): + def apply(self, total: float) -> float: + return total * 0.9 + + +class EmployeeDiscount(Discount): + def apply(self, total: float) -> float: + return total * 0.7 + + +DISCOUNTS: dict[str, type[Discount]] = { + "regular": NoDiscount, + "vip": VIPDiscount, + "employee": EmployeeDiscount, +} + + +def discount_for(customer_type: str) -> Discount: + """Выбрать стратегию скидки по типу клиента.""" + return DISCOUNTS.get(customer_type, NoDiscount)() diff --git a/app/legacy_report.py b/app/legacy_report.py new file mode 100644 index 0000000..01a0c69 --- /dev/null +++ b/app/legacy_report.py @@ -0,0 +1,41 @@ +"""Учебный модуль с высокой сложностью для метрического гейта CI. + +V(G) функции classify_order — ранг C (11–20). Поэтому: + + xenon --max-absolute C app/ # зелёный + xenon --max-absolute B app/ # красный — гейт не пропускает + +Мораль: порог в CI делает рост сложности видимым и блокирующим. +""" + + +def classify_order(order, cfg): + """Классифицировать заказ по десятку правил — намеренно сложно.""" + if order is None: + return "missing" + if "total" not in order: + return "no_total" + total = order["total"] + if total < 0: + return "negative" + if total == 0: + return "empty" + if total > 100000: + return "huge" + if cfg.get("maintenance"): + return "maintenance" + if cfg.get("blocked"): + return "blocked" + if cfg.get("regions") and order.get("region") not in cfg["regions"]: + return "region" + if order.get("coupon") and not cfg.get("coupons", False): + return "coupon" + if total > cfg.get("limit", 10000): + return "limit" + if order.get("fraud") and order.get("manual_review"): + return "fraud_review" + elif order.get("fraud"): + return "fraud" + if order.get("vip") and total > 5000: + return "vip_review" + return "ok" diff --git a/app/service.py b/app/service.py new file mode 100644 index 0000000..1540a18 --- /dev/null +++ b/app/service.py @@ -0,0 +1,21 @@ +"""Сервис заказов демо-проекта ci-demo (СТРПО, неделя 9, Gitea edition).""" +from dataclasses import dataclass + +from .discounts import Discount + + +@dataclass +class Order: + items: dict[str, float] + + @property + def total(self) -> float: + return sum(self.items.values()) + + +class OrderService: + def __init__(self, discount: Discount) -> None: + self.discount = discount + + def total(self, order: Order) -> float: + return round(self.discount.apply(order.total), 2) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f0e4c8a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..68899a4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +pytest>=8.0,<9 +pytest-cov>=5.0,<7 +ruff>=0.6,<1 +radon>=6.0,<7 +xenon>=0.9,<1 diff --git a/tests/test_discounts.py b/tests/test_discounts.py new file mode 100644 index 0000000..f1cdb96 --- /dev/null +++ b/tests/test_discounts.py @@ -0,0 +1,36 @@ +import sys + +import pytest + +from app.discounts import NoDiscount, discount_for +from app.service import Order, OrderService + + +def make_order() -> Order: + return Order({"чай": 300, "кофе": 700}) + + +def test_vip_discount() -> None: + service = OrderService(discount_for("vip")) + assert service.total(make_order()) == pytest.approx(900.0) + + +def test_employee_discount() -> None: + service = OrderService(discount_for("employee")) + assert service.total(make_order()) == pytest.approx(700.0) + + +def test_regular_has_no_discount() -> None: + service = OrderService(discount_for("regular")) + assert service.total(make_order()) == pytest.approx(1000.0) + + +def test_unknown_type_falls_back_to_regular() -> None: + assert isinstance(discount_for("unknown"), NoDiscount) + + +def test_python_version_supported() -> None: + # Для акта с матрицей: на Python 3.11 этот тест падает + assert sys.version_info >= (3, 12), ( + f"нужен Python 3.12+, получено {sys.version.split()[0]}" + ) diff --git a/tests/test_legacy_report.py b/tests/test_legacy_report.py new file mode 100644 index 0000000..1c03d14 --- /dev/null +++ b/tests/test_legacy_report.py @@ -0,0 +1,25 @@ +import pytest + +from app.legacy_report import classify_order + +CASES = [ + (None, {}, "missing"), + ({}, {}, "no_total"), + ({"total": -1}, {}, "negative"), + ({"total": 0}, {}, "empty"), + ({"total": 200000}, {}, "huge"), + ({"total": 100}, {"maintenance": True}, "maintenance"), + ({"total": 100}, {"blocked": True}, "blocked"), + ({"total": 100, "region": "eu"}, {"regions": ["us"]}, "region"), + ({"total": 100, "coupon": "X"}, {"coupons": False}, "coupon"), + ({"total": 20000}, {"limit": 10000}, "limit"), + ({"total": 100, "fraud": True, "manual_review": True}, {}, "fraud_review"), + ({"total": 100, "fraud": True}, {}, "fraud"), + ({"total": 6000, "vip": True}, {}, "vip_review"), + ({"total": 100}, {}, "ok"), +] + + +@pytest.mark.parametrize("order,cfg,expected", CASES) +def test_classify_order(order, cfg, expected): + assert classify_order(order, cfg) == expected diff --git a/workflows_заготовки/bad.yml b/workflows_заготовки/bad.yml new file mode 100644 index 0000000..adeb4b9 --- /dev/null +++ b/workflows_заготовки/bad.yml @@ -0,0 +1,15 @@ +# ВНИМАНИЕ: демонстрационно плохой workflow — только для учебного репозитория! +# Используется в бонусном акте: показать «декоративный» CI (|| true). +name: mega + +on: pull_request + +jobs: + everything: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pip install -r requirements.txt + - run: ruff check . || true + - run: pytest -q || true + - run: echo "deploy --token=ghp_xxxxxxxxxxxxxxxxxxxx" diff --git a/workflows_заготовки/ci-artifacts.yml b/workflows_заготовки/ci-artifacts.yml new file mode 100644 index 0000000..a5c3f8d --- /dev/null +++ b/workflows_заготовки/ci-artifacts.yml @@ -0,0 +1,26 @@ +# Заготовка для акта «Артефакты» (Gitea Actions): скопировать поверх .gitea/workflows/ci.yml +# upload-artifact@v3 — максимально совместим с Gitea Actions; +# на новых версиях Gitea можно заменить на @v4. +name: CI (artifacts) + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install -r requirements.txt + - run: pytest -q --cov=app --cov-branch --cov-fail-under=60 --cov-report=xml + - uses: actions/upload-artifact@v3 + if: always() + with: + name: coverage-report + path: coverage.xml diff --git a/workflows_заготовки/ci-matrix.yml b/workflows_заготовки/ci-matrix.yml new file mode 100644 index 0000000..79ac68b --- /dev/null +++ b/workflows_заготовки/ci-matrix.yml @@ -0,0 +1,23 @@ +# Заготовка для акта «Матрица» (Gitea Actions): скопировать поверх .gitea/workflows/ci.yml +name: CI (matrix) + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - run: pip install -r requirements.txt + - run: pytest -q diff --git a/workflows_заготовки/ci-metrics-strict.yml b/workflows_заготовки/ci-metrics-strict.yml new file mode 100644 index 0000000..6e38bfc --- /dev/null +++ b/workflows_заготовки/ci-metrics-strict.yml @@ -0,0 +1,43 @@ +name: CI (strict metrics) + +on: + pull_request: + push: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install -r requirements.txt + - run: ruff check . + + test: + needs: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install -r requirements.txt + - run: pytest -q --cov=app --cov-branch --cov-fail-under=60 + + metrics: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install -r requirements.txt + - run: radon cc app -s -a + - run: xenon --max-absolute B app/ diff --git a/Сценарий_демонстрации.md b/Сценарий_демонстрации.md new file mode 100644 index 0000000..07721e6 --- /dev/null +++ b/Сценарий_демонстрации.md @@ -0,0 +1,158 @@ +# Сценарий демонстрации CI на лекции (Gitea Actions) + +**К дисциплине**: «Современные технологии разработки программного обеспечения» (СТРПО), неделя 9 +**Основа**: лекция `../09l_л_Метрики_сложности.md`, блок CI (слайды 18–24) +**Стенд**: эта папка `Демо_CI_Gitea`; GitHub-версия — `../Демо_CI` (мини-проект «калькулятор скидок») +**Длительность**: 10–12 минут; бонусные акты — ещё 4 минуты +**Формат**: живой показ на проекторе; реплики лектора — в блоках «Что говорить» + +## Карта актов ↔ слайды лекции + +| Акт | Что показываем | Слайд | +|---|---|---| +| 0 | Подготовка стенда (до лекции) | — | +| 1 | Зелёный локальный прогон, устройство пайплайна | «GitHub Actions: минимальный workflow» | +| 2 | Красный PR, лог падения, merge заблокирован | «Падай рано: правило 10 минут» | +| 3 | Лечение теста, зелёный прогон, merge, бейдж | «GitHub Actions: минимальный workflow» | +| 4 | Метрический гейт: `radon` + `xenon` | «Метрики в CI» | +| Бонус | Кеш, матрица, артефакты, «плохой» CI | «Стадии пайплайна» | + +## Отличия Gitea-варианта + +- workflow лежит в `.gitea/workflows/ci.yml`; +- прогоны выполняет act_runner; +- защита ветки — Status Check Contexts в Settings → Branches. + +## Акт 0. Подготовка (до лекции, 15–20 минут) + +1. Создайте пустой репозиторий `ci-demo` на сервере Gitea. +2. Запушьте содержимое этой папки как корень репозитория: + +```powershell +git init +git add . +git commit -m "Стартовый каркас демо" +git branch -M main +git remote add origin https://gitea.example.com/OWNER/ci-demo.git +git push -u origin main +``` + +3. Замените `gitea.example.com` и `OWNER` в бейдже `README.md`. +4. Прогрейте кеш: дождитесь зелёного первого прогона act_runner. +5. Защита ветки: обязательные Status Check Contexts `lint`, `test`, `metrics`. + +**Чек-лист готовности:** первый прогон зелёный; проверки `lint`, `test`, `metrics` +видны в branch protection; кеш прогрет. + +## Акт 1. Зелёный старт (1–2 мин) + +Покажите структуру: `app/`, `tests/`, `.gitea/workflows/ci.yml`. Запустите локально: + +```powershell +pytest -q +``` + +**Ожидаемо:** все тесты зелёные (сейчас 19). Откройте Actions — последний прогон на `main` зелёный. + +**Что говорить.** «Локально зелёный — и в CI зелёный. CI не делает ничего волшебного: +он повторяет те же шаги — checkout, Python, зависимости, pytest. Workflow — ваш +локальный ритуал, записанный в YAML». + +Покажите `ci.yml`: `on:` — триггер, `jobs.lint` — стиль, `jobs.test` — тесты +с порогом покрытия, `jobs.metrics` — `radon` и `xenon`, `needs` — порядок и гейт. + +## Акт 2. Красный PR (3 мин) + +```powershell +git switch -c feature/vip-095 +``` + +В `app/discounts.py` измените скидку VIP: `return total * 0.9` → `return total * 0.95`. +Тесты не трогайте. + +```powershell +git add -A +git commit -m "VIP: скидка 0.95 (требование изменено)" +git push -u origin feature/vip-095 +``` + +Откройте PR: `lint` — зелёный, `test` — красный, `metrics` — не запускается. +В логе видно `assert ... pytest.approx(900.0)` и фактическое `950.0`. +Кнопка merge заблокирована. + +**Что говорить.** «Тест красный — merge нельзя. Это подарок: ошибка поймана за минуту. +Красная main — stop-the-line. Упал только `test`; `lint` прошёл и не ждал тестов: +быстрые проверки — первыми». + +## Акт 3. Лечим тест, мёржим (2 мин) + +**Спросите:** что правильнее — вернуть код или обновить тест? Ответ: требование +изменилось, значит меняется тест. + +В `tests/test_discounts.py`: `pytest.approx(900.0)` → `pytest.approx(950.0)`. + +```powershell +git commit -am "Тест VIP приведён к новому требованию 0.95" +git push +``` + +Дождитесь зелёного прогона и нажмите Merge. Покажите бейдж в README. + +**Что говорить.** «Зелёная галочка на PR — Definition of Done в действии: код, +ревью, зелёный CI, merge. Без гейта DoD — просто список пожеланий». + +## Акт 4. Метрический гейт (2–3 мин) + +**Действия.** + +```powershell +radon cc app -s -a +xenon --max-absolute C app/ +``` + +**Ожидаемо:** `legacy_report.py:12 classify_order — C (17)`, гейт C зелёный. +Теперь скопируйте строгую заготовку: + +```powershell +Copy-Item workflows_заготовки/ci-metrics-strict.yml .gitea/workflows/ci.yml +git switch -c demo/strict-metrics +git add . +git commit -m "Демо: строгий метрический гейт" +git push -u origin demo/strict-metrics +``` + +В PR `metrics` красный: `xenon --max-absolute B` не пропускает ранг C. +PR закройте, верните основной workflow. + +**Что говорить.** «Метрика работает, когда её нельзя игнорировать. Порог C +пропускает функцию сложности 17; порог B — нет. Рост сложности становится +видимым и блокирующим, а не темой для спора на ревью». + +## Бонусные акты (по времени) + +- **Кеш**: сравните Duration первого и последнего прогонов; найдите `Cache hit`. +- **Матрица**: `Copy-Item workflows_заготовки/ci-matrix.yml ...` → 3.11 красный, + 3.12/3.13 зелёные; PR не мёржить. +- **Артефакты**: `ci-artifacts.yml` → скачайте `coverage-report`. +- **Плохой CI**: `bad.yml` с `|| true` — тест падает, а проверка зелёная. + +## Если что-то пошло не так + +- Первый прогон медленный — кеша ещё нет; прогрейте заранее. +- Branch protection не видит проверки — обновите страницу после первого прогона. +- Раннеры тормозят — покажите заранее сделанные прогоны по закладкам. + +## Уборка + +```powershell +git switch main +git checkout main -- .gitea/workflows/ci.yml +git push origin --delete feature/vip-095 demo/strict-metrics +``` + +## Шпаргалка ответов + +- **Почему отдельные job'ы?** Параллельность + `needs` задаёт порядок и гейт. +- **Что такое runner?** Чистая машина под прогон; состояние не сохраняется. +- **Можно ли доверять зелёному CI?** Только если проверки настоящие: без `|| true`. +- **Чем CI отличается от CD?** CI — интеграция и проверки; CD — доставка, отдельная тема. diff --git a/Сценарий_демонстрации.pdf b/Сценарий_демонстрации.pdf new file mode 100644 index 0000000..be9bdd9 Binary files /dev/null and b/Сценарий_демонстрации.pdf differ