/
liquid-g
/
liquid-code
Обзор
Документация
Войти
/
liquid-g
/
liquid-code
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
develop-0.2.0
src/liquidcode/commands/base.py
369 строк
14 KB
User
0.3.0-RC1 - баг при создании роута в контроллере.
04 июл 2026, 18:41
04 июл 2026, 18:41
53a70d3
Код
Авторство
О чём код?
""" Базовые утилиты для консольных генераторов кода. Содержит функции для работы с файлами, загрузки и рендеринга шаблонов, а также универсальную функцию generate_from_template. """ import argparse import os import re from typing import Any, Dict, List, Optional # Используем Jinja2 для рендеринга шаблонов try: from jinja2 import Environment, BaseLoader except ImportError: raise ImportError( "Jinja2 is required for template rendering. " "Install with: pip install Jinja2" ) # Директория с шаблонами (относительно текущего файла) TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), 'templates') class TemplateError(Exception): """Исключение, связанное с ошибками в шаблонах.""" pass def create_file(path: str, content: str, overwrite: bool = False) -> bool: """ Создаёт файл с заданным содержимым. Args: path: Путь к файлу. content: Содержимое. overwrite: Перезаписывать существующий файл. Returns: True, если файл создан, False, если существовал и overwrite=False. Raises: IOError: При ошибке записи. """ if os.path.exists(path) and not overwrite: return False try: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, 'w', encoding='utf-8') as f: f.write(content) except (OSError, PermissionError) as e: raise IOError(f"Не удалось записать файл {path}: {e}") from e return True def load_template_file(template_name: str, extension: str = '.py.j2') -> str: """ Загружает содержимое файла шаблона. Args: template_name: Имя шаблона (например, 'controller', 'entity', 'api'). extension: Расширение файла шаблона (по умолчанию '.py.j2'). Returns: Содержимое файла шаблона. Raises: TemplateError: Если файл не найден или не удалось прочитать. """ filename = f"{template_name}{extension}" filepath = os.path.join(TEMPLATES_DIR, filename) if not os.path.exists(filepath): raise TemplateError(f"Файл шаблона не найден: {filepath}") try: with open(filepath, 'r', encoding='utf-8') as f: return f.read() except OSError as e: raise TemplateError(f"Не удалось прочитать шаблон {filepath}: {e}") from e def render_template(template_str: str, **kwargs: Any) -> str: """ Рендерит шаблон с использованием Jinja2. Аргументы: template_str: Строка шаблона с синтаксисом Jinja2. **kwargs: Значения для подстановки. Returns: Отрендеренная строка. Raises: TemplateError: При ошибке рендеринга. """ try: env = Environment(loader=BaseLoader()) template = env.from_string(template_str) return template.render(**kwargs) except Exception as e: raise TemplateError(f"Ошибка рендеринга шаблона: {e}") from e def get_template(template_name: str, extension: str = '.py.j2', **kwargs: Any) -> str: """ Загружает и рендерит шаблон по имени. Args: template_name: Имя шаблона ('controller', 'entity', 'middleware', 'crud', 'api'). extension: Расширение файла шаблона (по умолчанию '.py.j2'). **kwargs: Параметры подстановки. Returns: Строка с содержимым файла. Raises: TemplateError: Если шаблон не найден или ошибка рендеринга. """ template_str = load_template_file(template_name, extension) return render_template(template_str, **kwargs) def normalize_class_name(name: str, suffix: str = '') -> str: """ Приводит имя класса к формату CamelCase, убирает дублирующий суффикс. Args: name: Исходное имя (например, 'user', 'UserController'). suffix: Суффикс, который нужно удалить (например, 'Controller'). Returns: Нормализованное имя (например, 'User'). Raises: ValueError: Если имя пустое или содержит недопустимые символы. """ name = name.strip().replace(' ', '') if not name: raise ValueError("Имя не может быть пустым") if not name[0].isalpha(): raise ValueError("Имя должно начинаться с буквы") if not name.isalnum(): raise ValueError("Имя должно содержать только буквы и цифры") normalized = name[0].upper() + name[1:] if name else '' if suffix and normalized.endswith(suffix) and normalized != suffix: normalized = normalized[:-len(suffix)] return normalized def normalize_route(route: str) -> str: """ Приводит маршрут к нормальному виду: добавляет начальный слеш, убирает пробелы. Args: route: Исходный маршрут (например, 'users', '/users'). Returns: Нормализованный маршрут (например, '/users'). Raises: ValueError: Если маршрут содержит пробелы или недопустимые символы. """ route = route.strip() if ' ' in route: raise ValueError("Маршрут не должен содержать пробелы") if not re.match(r'^[a-zA-Z0-9_\-/:{}]*$', route): raise ValueError("Маршрут содержит недопустимые символы") # Убираем начальный и конечный слеши route = route.strip('/') return route def ask_field() -> Optional[Dict[str, Any]]: """ Интерактивно запрашивает поле для сущности. Возвращает словарь с полями: name, type, nullable, length. Если пользователь ввёл пустое имя, возвращает None. """ print("\nДобавление поля (оставьте имя пустым для завершения):") name = input("Имя поля: ").strip() if not name: return None if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name): print("❌ Недопустимое имя поля. Используйте буквы, цифры, подчёркивание.") return ask_field() ftype = input("Тип (string, int, float, bool, list, dict, ...) [string]: ").strip() or "string" nullable = input("Может быть null? (y/n) [n]: ").strip().lower() == 'y' length = input("Длина (для string, опционально): ").strip() if length: try: length = int(length) except ValueError: length = None else: length = None return { 'name': name, 'type': ftype, 'nullable': nullable, 'length': length, } def ask_fields() -> List[Dict[str, Any]]: """Циклически запрашивает поля до тех пор, пока пользователь не введёт пустое имя.""" fields = [] while True: field = ask_field() if field is None: break fields.append(field) return fields def ask_methods() -> List[str]: """Интерактивный выбор методов для контроллера.""" methods = [ ('index', 'GET /', 'список'), ('show', 'GET /{id}', 'просмотр'), ('create', 'POST /', 'создание'), ('update', 'PUT /{id}', 'обновление'), ('delete', 'DELETE /{id}', 'удаление'), ] print("\nВыберите методы для контроллера:") for i, (name, route, desc) in enumerate(methods, 1): print(f" {i}. {name} ({route}) — {desc}") default = "1,2,3,4,5" choice = input(f"Введите номера через запятую (по умолчанию все: {default}): ").strip() if not choice: choice = default try: selected = [int(x.strip()) for x in choice.split(',') if x.strip()] return [methods[i - 1][0] for i in selected if 1 <= i <= len(methods)] except ValueError: print("Неверный ввод. Будут созданы все методы.") return [m[0] for m in methods] # Маппинг псевдонимов типов к Python-типам TYPE_MAP = { 'string': 'str', 'int': 'int', 'float': 'float', 'bool': 'bool', 'list': 'list', 'dict': 'dict', 'tuple': 'tuple', 'set': 'set', } def normalize_field_type(ftype: str, nullable: bool = False) -> str: """ Приводит тип поля к корректному имени Python с учётом Optional. Args: ftype: Исходный тип (например, 'string'). nullable: Флаг, указывающий, может ли поле быть None. Returns: Нормализованный тип (например, 'str' или 'Optional[str]'). """ normalized = TYPE_MAP.get(ftype, ftype) if nullable: normalized = f"Optional[{normalized}]" return normalized # Зарезервированные слова Python, которые нельзя использовать как имена полей RESERVED_WORDS = { 'class', 'from', 'import', 'return', 'yield', 'lambda', 'pass', 'break', 'continue', 'del', 'elif', 'else', 'except', 'finally', 'for', 'global', 'if', 'in', 'is', 'nonlocal', 'not', 'or', 'raise', 'try', 'while', 'with', 'assert', 'async', 'await', 'def', 'and', 'as', 'None', 'True', 'False' } def safe_field_name(name: str) -> str: """ Возвращает безопасное имя поля, добавляя суффикс `_field`, если имя совпадает с зарезервированным словом Python. Args: name: Исходное имя поля. Returns: Безопасное имя поля. """ if name in RESERVED_WORDS: return f"{name}_field" return name def extract_routes_from_file(filepath: str) -> List[str]: """ Извлекает все маршруты из файла контроллера (строки с @route). Возвращает список путей. """ routes = [] try: with open(filepath, 'r', encoding='utf-8') as f: content = f.read() # Ищем строки типа @route('/path') или @route(r'/path') pattern = r'@route\s*\(\s*r?(["\'])([^"\']+)\1' matches = re.findall(pattern, content) for match in matches: routes.append(match[1]) except Exception: pass return routes def generate_from_template( template_name: str, args: argparse.Namespace, extra_kwargs: Optional[Dict[str, Any]] = None, default_filename: Optional[str] = None, default_dir: str = 'src', template_extension: str = '.py.j2', ) -> bool: """ Универсальная функция для генерации файла из шаблона. Args: template_name: Имя шаблона ('controller', 'entity', 'middleware', 'crud', 'api'). args: Объект argparse.Namespace (должен содержать name, возможно directory, force). extra_kwargs: Дополнительные параметры для подстановки. default_filename: Шаблон имени файла с {name}. default_dir: Базовая директория по умолчанию. template_extension: Расширение файла шаблона (по умолчанию '.py.j2'). Returns: True, если файл создан, иначе False (файл существует и не force). Raises: TypeError: Если args не является argparse.Namespace. ValueError: Если отсутствует атрибут 'name'. TemplateError: При ошибках шаблона. IOError: При ошибке записи файла. """ if not isinstance(args, argparse.Namespace): raise TypeError("args должен быть экземпляром argparse.Namespace") name = getattr(args, 'name', None) if not name: raise ValueError("Отсутствует атрибут 'name' в args") target_dir = getattr(args, 'directory', default_dir) if not hasattr(args, 'directory') and default_dir: target_dir = default_dir if default_filename: filename = default_filename.format(name=name) else: filename = f"{name}.py" full_path = os.path.join(target_dir, filename) template_kwargs = {'name': name} if extra_kwargs: template_kwargs.update(extra_kwargs) content = get_template(template_name, extension=template_extension, **template_kwargs) overwrite = getattr(args, 'force', False) return create_file(full_path, content, overwrite)