diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..b72adceb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [ main, second-branch ] + pull_request: + branches: [ main, second-branch ] + +jobs: + lint: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12.3" + + - name: Cache pip + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-ruff-v1 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ruff + + - name: Auto-fix code with Ruff + run: | + ruff check . --fix --line-length 88 || true + + - name: Run Ruff linter + run: | + ruff check . --line-length 88 diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 00000000..aa31313b --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,10 @@ +line-length = 88 +select = ["E", "F"] +ignore = ["F401", "W391", "E501"] +exclude = [ + "venv", + "migrations", + ".git", + "__pycache__", + "node_modules" +] diff --git a/README.md b/README.md index 48de95e6..6c730951 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ git clone git@github.com:gardiys/fastapi-backend-course.git - [ ] [Урок 3: Настройка окружения](./setting_environment/) - [ ] [Урок 4: Использование хранилищ](./using_storage/) - [ ] [Урок 5: Архитектура проекта](./project_architecture/) -- [ ] [Урок 6: Общение сервисов](./services_communication/) -- [ ] [Урок 7: Тестирование](./testing/) -- [ ] [Урок 8: Деплой сервисов](./services_deploy/) -- [ ] [Урок 9: Отказоустойчивость](./fault_tolerance/) -- [ ] [Урок 10: Мониторинг](./monitoring/) +- [ ] [Урок 6: Фоновые задачи](./async_tasks/) +- [ ] [Урок 7: Общение сервисов](./services_communication/) +- [ ] [Урок 8: Тестирование](./testing/) +- [ ] [Урок 9: Деплой сервисов](./services_deploy/) +- [ ] [Урок 10: Отказоустойчивость](./fault_tolerance/) +- [ ] [Урок 11: Мониторинг](./monitoring/) diff --git a/async_tasks/README.md b/async_tasks/README.md new file mode 100644 index 00000000..e69de29b diff --git a/git/README.md b/git/README.md index 96f5d52d..14f7c822 100644 --- a/git/README.md +++ b/git/README.md @@ -5,12 +5,13 @@ 3. Создать новую ветку с названием задачи "pull-request-task" и переключиться на неё 4. В проекте создать два новых файла main.py и config.py 5. Закоммитить изменения и запушить ветку в репозиторий -6. Затем создать пулл-реквест +6. Затем создать пулл-реквест с ветки pull-request-task на ветку main ## Задание 2: разрешение конфликтов В пулл-реквесте по [ссылке](https://github.com/gardiys/fastapi-backend-course/pull/1) есть конфликт. Нужно: 1. Форкнуть к себе репозиторий 2. Разрешить конфликт в этой ветке, чтобы ветку можно было вмержить в мастер без потери логики +3. Проверить папку git/src с помощью линтера ruff на ошибки и несоответствия стандартам разработки на Python ## Задание 3: простой CI Нужно добавить конфигурацию CI с помощью GitHub Actions в свой репозиторий. diff --git a/git/src/main.py b/git/src/main.py index ca0839c3..704ed1cf 100644 --- a/git/src/main.py +++ b/git/src/main.py @@ -14,7 +14,7 @@ def load_books(filename='library.json'): except json.JSONDecodeError: return [] -def saving_books(books, filename='library.json'): +def save_books(books, filename='library.json'): """ Сохранение списка книг в JSON-файл. """ @@ -94,7 +94,7 @@ def main(): # Получаем новый список с добавленной книгой new_books = add_book(books, title, author, year) books = new_books # Обновляем переменную, чтобы сохранить изменения - saving_books(books) # Сразу сохраняем в файл + save_books(books) # Сразу сохраняем в файл print("Книга добавлена!") elif choice == '3': @@ -102,9 +102,10 @@ def main(): title_to_remove = input("Введите название книги, которую хотите удалить: ").strip() new_books = remove_book(books, title_to_remove) - if len(new_books) > len(books): + + if len(new_books) < len(books): books = new_books - saving_books(books) + save_books(books) print("Книга удалена!") else: print("Книга с таким названием не найдена.") @@ -119,17 +120,12 @@ def main(): else: print("Ничего не найдено.") - elif choice == '6': + elif choice == '5': print("Выход из программы.") break else: print("Некорректный ввод. Попробуйте ещё раз.") - - - - - if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/setting_environment/README.md b/setting_environment/README.md index be432212..9495789e 100644 --- a/setting_environment/README.md +++ b/setting_environment/README.md @@ -1,10 +1,30 @@ -Задание 1 -настройка Poetry -Задание 2 -Создание docker образа -Задание 3 -создание docker compose файла -Задание 4 -линтеры и pre-commit хуки -задание 5 -конфигурация проектов +# Задачи + +## Задание 1 +Переведите проект, полученный в разделе [Простой backend](/simple_backend/README.md) на Poetry. + +## Задание 2 +Установите в dev группу poetry линтер ruff. Настройте pre-commit хуки для ruff check и ruff format. +При каждом коммите у вас должны запускаться проверки с помощью линтеров. + +## Задание 3 +Заверните проект в Docker-образ. Критерии: +- Должен быть slim +- Внутри должны устанавливаться poetry зависимости +- Образ должен собираться +- С помощью образа можно запустить приложение на FastAPI + +## Задание 4 +Создайте docker-compose.yaml файл. Критерии: +- Должен быть сервис приложения +- Внутри должна быть команда запуска приложения + +## Задание 5 +Создайте makefile с основными командами для работы с вашим проектом. Критерии: +- Должны быть команды для запуска, перезагрузки, остановки проекта с помощью docker compose +- Должны быть команды для запуска pre-commit check + +## Задание 6 +Создайте файлы конфигурации `.env` и `.env.example`. `.env` должен быть в gitignore, а `.env.example` должен содержать структуру `.env` файла. +Подключите `.env` в docker compose с помощью `env_file:`. +Вынесите все свои токены из проекта в `.env` \ No newline at end of file diff --git a/simple_backend/README.md b/simple_backend/README.md index 856add4d..fd203c5b 100644 --- a/simple_backend/README.md +++ b/simple_backend/README.md @@ -1,3 +1,46 @@ -Задание 1 -- создайте приложение main.py на fastapi -- проверьте его на тестах \ No newline at end of file +# Задачи +**Во всех заданиях обязательно создание pull-request по аналогии из задачи блока git!** + +**Ваш код обязательно должен запускаться и проверяться на работоспособность** +## Задание 1 +Нужно локально запустить backend приложение. Пройдитесь по шагам: +1. Если еще не склонирован этот репозиторий, то склонируйте +2. Создайте в папке `simple_backend/src/task_tracker` виртуальное окружение +3. Активируйте `venv` +4. Установите записимости из `requirements.txt` +5. Запустите сервер с помощью команды `uvicorn main:app` +6. Перейдите по ссылке http://127.0.0.1:8000/docs и проверьте, что бэк работает +## Задание 2 +Вам нужно оживить бекенд из первой задачи и создать простой API для управления списком задач. Данные нужно хранить в оперативной памяти (например в списке Python). +В каждой из функций нужно прописать логику: +- get_tasks должен возвращать список всех задач +- create_task должен создавать новую задачу +- update_task должен обновлять информацию о задаче +- delete_task должен удалять задачу + +У задачи должны быть параметры: id, название, статус задачи. + +### Подзадачи +- Прочитайте, что такое "Хранение состояния", создайте в task_tracker readme.md файл и напишите в чём минусы подхода с хранением задач в оперативной памяти (списке python) +- Исправьте ситуацию и переделайте хранение информации о задачах в файле проекта. Информацию можно хранить например в формате json. +- Напишите в readme.md: + - что улучшилось после того, как список из оперативной памяти изменился на файл проекта? + - избавились ли мы таким способом от хранения состояния или нет? + - где еще можно хранить задачи и какие есть преимущества и недостатки этих подходов? +- Напишите класс для работы с файлом хранения задач в task_tracker и измените код проекта так, чтобы он работал с объектом этого класса. +- Сделайте свой backend - stateless с помощью интеграции с облачным сервисом (jsonbin.io, mockapi.io, github gist). Организуйте хранение и обновление json файла во внешнем сервисе. +- Прочитайте что такое "состояние гонки" и напишите в readme файле о том, какие проблемы остались в бекенде на данном этапе проекта. Есть ли у вас какое-то решение этой проблемы? + + +## Задание 3 +Давайте прокачаем наш таск-треккер. Хочется, чтобы текст задачи заливался в LLM модель и она выдавала способы решения задачи и добавляла к её тексту. +Для того, чтобы это сделать: +- Настройте интеграцию с сервисом [Cloudflare](https://developers.cloudflare.com/workers-ai/get-started/rest-api/) через REST API. Для этого создайте новый класс для работы с этой API. +- При создании новой задачи отправляйте запрос с её текстом в LLM и просьбой объяснить как решать задачу +- Добавляйте полученный ответ в текст задачи + +## Задание 4 +Заметили, что в коде для работы с файлами и в коде для работы с LLM API есть похожие участки? Давайте избавимся от дублирования через наследование. +- Сделайте базовый класс BaseHTTPClient и вынесите в него общие функции и методы из двух классов +- Сделайте наследование от базового класса в клиентах +- С помощью абстрактных классов реализуйте абстрактные методы, которые должны быть в классах наследниках diff --git a/simple_backend/orders.py b/simple_backend/orders.py new file mode 100644 index 00000000..54daeee7 --- /dev/null +++ b/simple_backend/orders.py @@ -0,0 +1,104 @@ + +class Order: + TAX_RATE = 0.08 # 8% налог + SERVICE_CHARGE = 0.05 # 5% сервисный сбор + + def __init__(self, customer): + self.customer = customer + self.dishes = [] + + def add_dish(self, dish): + if isinstance(dish, Dish): + self.dishes.append(dish) + else: + raise ValueError("Можно добавлять только объекты класса Dish.") + + def remove_dish(self, dish): + if dish in self.dishes: + self.dishes.remove(dish) + else: + raise ValueError("Такого блюда нет в заказе.") + + def calculate_total(self): + return sum(dish.price for dish in self.dishes) + + + def final_total(self): + total_after_discount = self.apply_discount() + total_with_tax = total_after_discount * (1 + Order.TAX_RATE) + final_total = total_with_tax * (1 + Order.SERVICE_CHARGE) + return final_total + + def apply_discount(self): + discount_rate = self.customer.get_discount() / 100 + return self.calculate_total() * (1 - discount_rate) + + def __str__(self): + dish_list = "\n".join([str(dish) for dish in self.dishes]) + return f"Order for {self.customer.name}:\n{dish_list}\nTotal: ${self.final_total():.2f}" + + +class GroupOrder(Order): + def __init__(self, customers): + super().__init__(customer=None) # Групповой заказ не привязан к одному клиенту + self.customers = customers + + def split_bill(self): + if not self.customers: + raise ValueError("Нет клиентов для разделения счета.") + total = self.final_total() + return total / len(self.customers) + + def __str__(self): + customer_list = ", ".join([customer.name for customer in self.customers]) + dish_list = "\n".join([str(dish) for dish in self.dishes]) + return f"Group Order for {customer_list}:\n{dish_list}\nTotal: ${self.final_total():.2f}" + +class Dish: + def __init__(self, name, price, category): + self.name = name + self.price = price + self.category = category + + def __str__(self): + return f"Dish: {self.name}, Category: {self.category}, Price: ${self.price:.2f}" + +class Customer: + def __init__(self, name, membership="Regular"): + self.name = name + self.membership = membership + + def get_discount(self): + if self.membership == "VIP": + return 10 # VIP клиенты получают 10% скидки + return 0 # Обычные клиенты не получают скидки + + def __str__(self): + return f"Customer: {self.name}, Membership: {self.membership}" +# Пример использования + +# Создаем блюда +pizza = Dish("Pizza", 12, "Main Course") +ice_cream = Dish("Ice Cream", 5, "Dessert") +coffee = Dish("Coffee", 3, "Drink") + +# Создаем клиентов +regular_customer = Customer("Alice", "Regular") +vip_customer = Customer("Bob", "VIP") + +# Индивидуальный заказ +order1 = Order(regular_customer) +order1.add_dish(pizza) +order1.add_dish(ice_cream) + +print(order1) # Вывод информации о заказе +print(f"Final Total: ${order1.final_total():.2f}") # Итоговая стоимость + +# Групповой заказ +group_order = GroupOrder([regular_customer, vip_customer]) +group_order.add_dish(pizza) +group_order.add_dish(ice_cream) +group_order.add_dish(coffee) + +print(group_order) # Вывод информации о групповом заказе +print(f"Split Bill: ${group_order.split_bill():.2f} per person") # Стоимость на каждого \ No newline at end of file diff --git a/simple_backend/src/task_tracker/BaseHTTPClient.py b/simple_backend/src/task_tracker/BaseHTTPClient.py new file mode 100644 index 00000000..d5f328e2 --- /dev/null +++ b/simple_backend/src/task_tracker/BaseHTTPClient.py @@ -0,0 +1,34 @@ +import requests +from abc import ABC, abstractmethod + +class BaseHTTPClient(ABC): + + def __init__(self, base_url: str, headers: dict = None): + self.base_url = base_url.rstrip("/") + self.headers = headers or {} + + def _get(self, endpoint: str = "", **kwargs): + url = f"{self.base_url}{endpoint}" + print(f"[GET] {url}") + resp = requests.get(url, headers=self.headers, timeout=15, **kwargs) + resp.raise_for_status() + return resp.json() + + def _post(self, endpoint: str = "", json_data=None, **kwargs): + url = f"{self.base_url}{endpoint}" + print(f"[POST] {url} | DATA: {json_data}") + resp = requests.post(url, headers=self.headers, json=json_data, timeout=15, **kwargs) + resp.raise_for_status() + return resp.json() + + def _patch(self, endpoint: str = "", json_data=None, **kwargs): + url = f"{self.base_url}{endpoint}" + print(f"[PATCH] {url} | DATA: {json_data}") + resp = requests.patch(url, headers=self.headers, json=json_data, timeout=15, **kwargs) + resp.raise_for_status() + return resp.json() + + @abstractmethod + def _make_request(self, *args, **kwargs): + pass + \ No newline at end of file diff --git a/simple_backend/src/task_tracker/Cloudflare.py b/simple_backend/src/task_tracker/Cloudflare.py new file mode 100644 index 00000000..5bea74e0 --- /dev/null +++ b/simple_backend/src/task_tracker/Cloudflare.py @@ -0,0 +1,30 @@ +import os +import requests +from dotenv import load_dotenv +from BaseHTTPClient import BaseHTTPClient +load_dotenv() +class Cloudflare(BaseHTTPClient): + def __init__(self, api_key: str = None, acc_id: str = None): + self.api_key = api_key or os.getenv("CLOUDFLARE_API_KEY") + self.acc_id = acc_id or os.getenv("CLOUDFLARE_ACCOUNT_ID") + base_url = f"https://api.cloudflare.com/client/v4/accounts/{self.acc_id}/ai/run/@cf/meta/llama-3-8b-instruct" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + } + super().__init__(base_url=base_url, headers=headers) + def _make_request(self, text: str): + payload = { + "messages": [ + {"role": "system", "content": "You are a helpful assistant that explains tasks clearly."}, + {"role": "user", "content": text} + ] + } + return self._post(json_data=payload) + def get_llm_response(self, text:str) -> str: + try: + data = self._make_request(text) + return data.get('result', {}).get('response', "No response") + except Exception as e: + print("Error getting LLM response:", e) + return "Error: wrong API answer" \ No newline at end of file diff --git a/simple_backend/src/task_tracker/README.md b/simple_backend/src/task_tracker/README.md new file mode 100644 index 00000000..765f273c --- /dev/null +++ b/simple_backend/src/task_tracker/README.md @@ -0,0 +1,26 @@ +Хранение состояния + ++ 1.Минусы метода хранения в оперативной памяти в том, что доступ к данным есть только во время одной сессии , + после перезагрузки приложения, доступа к данным уже не будет. ++ 2.Отсутствие масштабируемости. ++ 3.Нет истории изменений +----------------------------------------------------------------------------------------------------------------------------- +Переход на json + ++ 1.После того, как список оперативной памяти изменился на файл проекта, мы получаем доступ к данным хранилища даже после перезапуска приложения, данные не исчезают, а остаются в списке ++ 2.Можно открыть файл и редактировать его вручную ++ 3.Так как json файл является локальным, то при создании копий приложения, у них будут разные файлы tasks.json +----------------------------------------------------------------------------------------------------------------------------- +Хранилища задач + +Задачи можно хранить в облачных хранилищах: + ++ Плюсы: Доступ откуда угодно ++ Минусы: Обдачные хранилища зависимы от сторонних ресурсов +Задачи можно хранить в баазах данныхЖ + ++ Плюсы: Поддержка транзакций, масштабирование и надежность ++ Минусы: Сложная настройка +----------------------------------------------------------------------------------------------------------------------------- +Состояние гонки, это когда два разработчика делают одно и то же в коде и возникает конфликт(данные одного разработчика перезаписывают данные другого. +Если перенести хранение на базу данных , то решится проблема с гонками, плюс данные лучше защищены и легче масштабировать diff --git a/simple_backend/src/task_tracker/main.py b/simple_backend/src/task_tracker/main.py new file mode 100644 index 00000000..cd272e3f --- /dev/null +++ b/simple_backend/src/task_tracker/main.py @@ -0,0 +1,42 @@ +'''backend''' +from fastapi import FastAPI, HTTPException +from storage_gist import GistStorage +from Cloudflare import Cloudflare +app = FastAPI() +storage = GistStorage() +llm = Cloudflare() +# return tasks list +@app.get("/tasks") +def get_tasks(): + return storage.load() +#create a new task with id, name, and status +@app.post("/tasks") +def create_task(name: str, condition: str = 'new'): + tasks = storage.load() + new_id = len(tasks) + 1 + llm_response = llm.get_llm_response(name) + task = {'id': new_id, 'name': name, 'condition': condition, 'description': f'LLM Suggestion: {llm_response}'} + tasks.append(task) + storage.save(tasks) + return task +#update task +@app.put("/tasks/{task_id}") +def update_task(task_id: int, name: str, condition: str): + tasks = storage.load() + for task in tasks: + if task['id'] == task_id: + task['name'] = name + task['condition'] = condition + storage.save(tasks) + return task + raise HTTPException(status_code = 404, detail = 'task not found') +#delete task +@app.delete("/tasks/{task_id}") +def delete_task(task_id: int): + tasks = storage.load() + for task in tasks: + if task['id'] == task_id: + tasks.remove(task) + storage.save(tasks) + return 'task deleted' + raise HTTPException(status_code = 404, detail = 'task not found') \ No newline at end of file diff --git a/simple_backend/src/task_tracker/requirements.txt b/simple_backend/src/task_tracker/requirements.txt new file mode 100644 index 00000000..8e0578a0 --- /dev/null +++ b/simple_backend/src/task_tracker/requirements.txt @@ -0,0 +1,2 @@ +fastapi +uvicorn[standard] \ No newline at end of file diff --git a/simple_backend/src/task_tracker/storage.py b/simple_backend/src/task_tracker/storage.py new file mode 100644 index 00000000..ce237692 --- /dev/null +++ b/simple_backend/src/task_tracker/storage.py @@ -0,0 +1,13 @@ +import json +import os +class TaskStorage: + def __init__(self, filename: str = 'tasks.json'): + self.file = filename + if not os.path.exists(self.file): # if file does not exist, create file with empty list + self.save([]) + def load(self): + with open(self.file, 'r', encoding = 'utf-8') as f: + return json.load(f) + def save(self, tasks): + with open(self.file, 'w', encoding = 'utf-8') as f: + json.dump(tasks, f) \ No newline at end of file diff --git a/simple_backend/src/task_tracker/storage_gist.py b/simple_backend/src/task_tracker/storage_gist.py new file mode 100644 index 00000000..34c9972a --- /dev/null +++ b/simple_backend/src/task_tracker/storage_gist.py @@ -0,0 +1,44 @@ +import os +import requests +import json +from dotenv import load_dotenv +from BaseHTTPClient import BaseHTTPClient +load_dotenv() +GITHUB_API = 'https://api.github.com' +class GistStorage(BaseHTTPClient): + def __init__(self, token: str = None, gist_id: str = None, filename: str = 'tasks.json'): + self.token: str = token or os.getenv('GITHUB_TOKEN') + self.gist_id: str = gist_id or os.getenv('GIST_ID') + self.filename: str = filename + base_url = f"{GITHUB_API}/gists/{self.gist_id}" + if not self.token or not self.gist_id: + raise RuntimeError('GITHUB_TOKEN and GIST_ID must be given') + headers: dict = { + 'Authorization': f'token {self.token}', + 'Accept': 'application/vnd.github+json' + } + super().__init__(base_url=base_url, headers=headers) + def _make_request(self, *args, **kwargs): + pass + # loading task list from gist + def load(self) -> list[dict]: + gist_data = self._get() + files = gist_data.get("files", {}) + if self.filename not in files: + return [] + try: + content = files[self.filename]["content"] + data = json.loads(content) + return data.get("tasks", []) + except json.JSONDecodeError: + return [] + # saving task list on gist + def save(self, tasks: list[dict]) -> None: + body: dict = { + 'files': { + self.filename: { + 'content': json.dumps({'tasks': tasks}) + } + } + } + self._patch(json_data=body) \ No newline at end of file diff --git a/simple_backend/src/task_tracker/tasks.json b/simple_backend/src/task_tracker/tasks.json new file mode 100644 index 00000000..80c933c8 --- /dev/null +++ b/simple_backend/src/task_tracker/tasks.json @@ -0,0 +1 @@ +[{"id": 1, "name": "finaltask", "condition": "updated"}, {"id": 2, "name": "totaltask", "condition": "updated"}, {"id": 3, "name": "Task3", "condition": "new"}] \ No newline at end of file