diff --git a/app_config.py b/app_config.py new file mode 100644 index 0000000..dd37b97 --- /dev/null +++ b/app_config.py @@ -0,0 +1,60 @@ +"""Загрузка настроек приложения. Используется библиотека `configparser`.""" +import configparser +from os import path + + +class AppConfig: + """Класс для хранения настроек сервиса. + """ + + def __init__(self, path_to_conf_file: str, section: str = "Main"): + """ + Parameters + ---------- + path_to_conf_file: str + Путь к файлу конфига. Можно указывать относительный. + + section: str + Секция в конфиге, которую нужно считывать. По-умолчанию секция `[Main]`. + + Raises + ------ + KeyError + Если в конфиге не найдено указанной секции. + + + """ + cfg = configparser.ConfigParser() + + try: + cfg.read(path.abspath(path_to_conf_file)) + except FileNotFoundError: + print(f"File {path.abspath(path_to_conf_file)} not found!") + + if section not in cfg.sections(): + raise KeyError(f"Section {section} not found in config file!") + + conf = dict(cfg.items(section)) + + self.template: str = conf["template"] + self.path_for_config: str = conf["path_for_config"] + self.frequency_sec = int(conf["frequency_sec"]) + self.central_host_url: str = conf["central_host_url"] + self.requests_count: int = int(conf["requests_count"]) + self.request_portion: int = int(conf["request_portion"]) + + @property + def configs_path(self) -> str: + """Возвращает абсолютный путь до папки с конфигами.""" + _path = path.abspath(self.path_for_config) + return _path + + def __repr__(self): + return ( + f"template = {self.template}\n" + f"path_for_config = {self.path_for_config}\n" + f"{self.frequency_sec}\n" + f"{self.central_host_url}\n" + f"{self.requests_count}\n" + f"{self.request_portion}\n" + ) diff --git a/config_object.py b/config_object.py new file mode 100644 index 0000000..d7ca8db --- /dev/null +++ b/config_object.py @@ -0,0 +1,53 @@ +import asyncio +import os +import aiofiles + + +class ConfigObject: + def __init__(self, host: str, conf_body: str, path: str): + self.host = host + self.conf_body = conf_body.replace( + "server_name _;", f"server_name {host}.server.com;" + ) + + self.path = os.path.abspath(path) + + if not os.path.isdir(self.path): + os.mkdir(self.path) + + self.full_path_to_file: str = os.path.join(self.path, f"{host}.conf") + + @property + def existst(self) -> bool: + """Возвращает True, если файл конфига уже существует.""" + return os.path.isfile(self.full_path_to_file) + + async def write(self) -> bool: + """Записывает конфиг файл.""" + if not self.existst: + async with aiofiles.open(self.full_path_to_file, mode="w") as file: + await file.write(self.conf_body) + return True + + def __repr__(self): + return f"Hi, config for {self.host}" + + +class ConfigFactory: + def __init__(self, path_to_template: str, path_to_configs_dir: str): + self.templ = self.__read_config_template_file(path_to_template) + self.path = path_to_configs_dir + + def create(self, host: str) -> ConfigObject: + """Конструирует `ConfigObject` используя только хост.""" + return ConfigObject(host, self.templ, self.path) + + def __read_config_template_file(self, path_to_file: str) -> str: + """Прочесть шаблон конфига для сервера из файла.""" + template: str = "" + + _full_path = os.path.abspath(path_to_file) + with open(_full_path, mode="r", encoding="utf8") as file: + template = file.read() + + return template diff --git a/request_builder.py b/request_builder.py new file mode 100644 index 0000000..6651ad0 --- /dev/null +++ b/request_builder.py @@ -0,0 +1,86 @@ +"""Модуль для конструирования и обработки запросов.""" +import asyncio +from typing import Dict, List, NewType + +import aiohttp +from aiohttp import ClientResponse +from aiohttp.client_exceptions import ClientConnectorError, ServerDisconnectedError +from loguru import logger + +from app_config import AppConfig + +class RequestBulder: + """Конструктор запросов. + + Attributes + ---------- + cfg : AppConfig + Объект с конфигом для приложения. + + Methods + ------- + send_request(url: str, json_body: dict) + Асинхронный метод для отправки запроса. Может принимать пустой json_body. + """ + def __init__(self, cfg: AppConfig) -> None: + """Инициализация. Объект сесси `aiohttp.ClientSession` + создается здесь. """ + self.cfg = cfg + _conn = aiohttp.TCPConnector(limit=cfg.requests_count) + self.session = aiohttp.ClientSession(connector=_conn) + + async def __check_resp(self, resp: ClientResponse) -> bool: + if not resp.status == 200: + return False + + response = await resp.json() + + if not response["done"]: + return False + + return True + + async def send_request(self, url: str, json_body: dict) -> dict: + """Выполняет запрос, при успехе возвращает json с ответом, при + неудаче возвращает пустой dict. + + Parameters + ---------- + url : str + Адрес куда слать запрос. Нужна часть не включающая хост, только get/post. + + json_body: Json + Тело хапроса в Json формате. + + Returns + ------- + dict + Возвращает либо пустой dict, либо json ответа сервера (заполненный dict). + + Examples + -------- + >>> await send_request('count', json_body={} ) + + """ + _url = f"{self.cfg.central_host_url}/{url}" + try: + async with self.session.get(_url, json=json_body) as raw_resp: + if await self.__check_resp(raw_resp): + json_resp: dict = await raw_resp.json() + json_resp.pop("done") + return json_resp + except ClientConnectorError: + logger.error(f"Ошибка подключения к серверу {_url}") + # не придумал ничего умнее чем подождать frequency_sec из конфига + await asyncio.sleep(self.cfg.frequency_sec) + except ServerDisconnectedError: + logger.error(f"Сервер отклонил подключение {_url}") + return dict() + + async def wait(self) -> None: + """Ждет frequency_sec время.""" + await asyncio.sleep(self.cfg.frequency_sec) + + async def close(self) -> None: + """Gracefull shutdown connection.""" + await self.session.close() diff --git a/server.py b/server.py index 1fb7603..727705b 100644 --- a/server.py +++ b/server.py @@ -15,13 +15,14 @@ curl --request GET \ from flask import Flask, jsonify, request import json import secrets -import random +from random import randint +import time app = Flask(__name__) @app.route("/api/portal/get", methods=["GET"]) -def get(): +def index(): """ Единственная функция сервера-пирожочка. @@ -44,26 +45,15 @@ def get(): for host in range(0, limit): hosts.append({"hostname": f"content-creator-{secrets.token_hex(4)}"}) + time.sleep(randint(1, 6)) + return jsonify({"result": hosts, "done": True}) @app.route("/api/portal/count", methods=["GET"]) def count(): - """ - Единственная функция сервера-пирожочка. - - Вернет json, в формате: - { - "result": 100, - "done": true - } - - В этой реализации, принимает аргумент `limit=N`, N -- количество - генерируемых значений, а не выборка. - """ - # print(request) - - return jsonify({"result": random.randrange(1, 300), "done": True}) + time.sleep(randint(1, 2)) + return jsonify({"result": randint(20, 300), "done": True}) app.run() diff --git a/solution.py b/solution.py index 28f3c76..f10c35d 100644 --- a/solution.py +++ b/solution.py @@ -1,172 +1,82 @@ -from dataclasses import dataclass -from asyncio import Task -from typing import List - +"""Создание конфиг. файлов для хостов.""" +import argparse import asyncio import os -import configparser -import aiohttp -import aiofiles - - -@dataclass(frozen=True) -class Config: - """Класс для хранения конфига сервиса.""" - - template: str - path_for_config: str - frequency_sec: int - central_host_url: str - requests_count: int - request_portion: int - - -async def write_config_files(hosts: list, cfg: Config, template: str) -> None: - """Записываем конфиги для списка hosts.""" - - full_path_to_config_dir: str = os.path.abspath(cfg.path_for_config) - - if not os.path.isdir(full_path_to_config_dir): - os.mkdir(full_path_to_config_dir) - - for host in hosts: - config_filename: str = f"{host}.conf" - config_file = os.path.join(full_path_to_config_dir, config_filename) - - if not os.path.isfile(config_file): - config_body: str = template.replace( - "server_name _;", f"server_name {host}.server.com;" - ) - async with aiofiles.open(config_file, mode="w") as file: - await file.write(config_body) - - -def _get_template(templ_file: str) -> str: - """Возвращает шаблон конфига для хоста из файла `templ_file`.""" - - template: str = "" - with open(templ_file, mode="r", encoding="utf8") as file: - template = file.read() - - return template - +from asyncio import Task +from dataclasses import dataclass +from typing import List, Union -async def get_records_count(session, server) -> int: - """Возвращает количество записей в базе.""" - async with session.get(f"{server}/count") as resp: - resp = await resp.json() - count: int = int(resp["result"]) - return count +import aiohttp +from aiohttp.client_exceptions import ClientConnectorError +from loguru import logger +from app_config import AppConfig +from config_object import ConfigFactory, ConfigObject +from request_builder import RequestBulder -async def get_tasks( - url: str, portion: int, count: int, session: aiohttp.ClientSession, body: dict -) -> List[Task]: - """Вернет список задач с запросами к API. - Функция не ограничивает кол-во запросов, это нужно сделать до - вызова, чтобы передать корректный `session`. +async def get_records_count(rb: RequestBulder) -> int: + """Обертка для получения количества записей с сервера.""" + resp: dict = await rb.send_request("count", json_body={}) - Parameters - ---------- - url : str - Куда слать запрос. Ожидается "server/get" - portion : int - Сколько записей запрашивать за раз - count : int - Общее количество записей - session : aiohttp.ClientSession - Объект сессии, создается в уровне выше, с одним объектом - меньше накладных расходов на каждый запрос. - body : json - Json для запроса. - """ - tasks: List[Task] = [] + records_count: int = int(resp["result"]) if resp else 0 - for offset in range(0, count, portion): - tasks.append(asyncio.create_task(session.get(url, json=body))) - body["offset"] = offset + return int(records_count) - return tasks +async def custom_wrapper( + config_factory: ConfigFactory, + rb: RequestBulder, + usr: str, + json: dict, +) -> None: + """Обертка для создания конфигов и их записи.""" + resp: dict = await rb.send_request("get", json) -async def send_async_request(cfg: Config, json_body: dict) -> None: - """Начать серию запросов.""" - template: str = _get_template(cfg.template) + # Если мы получили валидный ответ, то разбираем пачку хостов из + # resp['result'] с созданием для каждой строки кофнига через + # фабрику + if resp: + conf_list = [config_factory.create(host["hostname"]) for host in resp["result"]] + await asyncio.gather(*[c.write() for c in conf_list]) + else: + logger.error(f"Сервер вернул ошибку") - # ограничим одновременное число запросов - conn = aiohttp.TCPConnector(limit=cfg.requests_count) - url = cfg.central_host_url - portion = cfg.request_portion +async def main(config_path: str) -> None: + """Точка входа.""" + cfg: AppConfig = AppConfig(config_path) - try: - async with aiohttp.ClientSession(connector=conn) as session: - # получаем количесвто записей - count = await get_records_count(session, url) + rb = RequestBulder(cfg) - tasks = await get_tasks(f"{url}/get", portion, count, session, json_body) + while True: + records_count = await get_records_count(rb) - responses = await asyncio.gather(*tasks) + json_boby_list = [] + for offset in range(0, records_count, cfg.request_portion): + body_json = { + "columns": "['hostname']", + "limit": cfg.request_portion, + "offset": offset, + } + json_boby_list.append(body_json) - # Пройдемся по ответам на запросы, запишем файлы конфига для - # каждого respone. Каждый response содержит portion или меньше хостов - for response in responses: - resp = await response.json() - hosts = [i["hostname"] for i in resp.get("result")] - await write_config_files(hosts, cfg, template) - except ClientConnectorError: - print(f"Невозможно подключиться к серверу {url}") + conf_factory = ConfigFactory(cfg.template, cfg.path_for_config) + await asyncio.gather( + *[custom_wrapper(conf_factory, rb, "get", jb) for jb in json_boby_list] + ) + await rb.wait() + # закрываем сессию + await rb.close() -def read_config(path_to_conf_file: str, section: str = "Main") -> Config: - """Считать конфиг с помощью `configparser`. - Parameters - ---------- - path_to_conf_file: str - Путь к файлу конфига. Можно указывать относительный. - - section: str - Секция в конфиге, которую нужно считывать. По-умолчанию секция [Main]. - - Raises - ------ - KeyError - Если в конфиге не найдено указанной секции. - - Returns - ------- - dict - Словарь, из значений указанной секции. - """ - config = configparser.ConfigParser() - config.read(os.path.abspath(path_to_conf_file)) - - if section not in config.sections(): - raise KeyError(f"Section {section} not found in config file!") - - conf = dict(config.items(section)) - - return Config( - template=conf["template"], - path_for_config=conf["path_for_config"], - frequency_sec=int(conf["frequency_sec"]), - central_host_url=conf["central_host_url"], - requests_count=int(conf["requests_count"]), - request_portion=int(conf["request_portion"]), +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Service for nginx config creation.") + parser.add_argument( + "--config_path", required=True, type=str, help="path for conifg file" ) + args = parser.parse_args() + config_path = os.path.abspath(args.config_path) - -async def main() -> None: - """Точка входа.""" - cfg: Config = read_config("service.conf") - - while True: - body = {"columns": "['hostname']", "limit": cfg.request_portion, "offset": 0} - await send_async_request(cfg, json_body=body) - await asyncio.sleep(cfg.frequency_sec) - - -if __name__ == "__main__": - asyncio.run(main()) + asyncio.run(main(config_path))