Документация Engee
Notebook

Работа с внешним API Engee (Personal Access Token)

В примере рассматриваем работу с внешним API Engee с использованием PAT (Personal Access Token) - реализован управляющий класс EngeeManager, функции проверки статуса, запуска, остановки, выполнения команды, загрузки и скачивания файлов.

В этом примере не рассматривается асинхронное исполнение кода, а также реализация доступа к API Engee по OAuth 2.0

Введение

Работа с Engee через внешний API открывает ещё более широкие возможности автоматизации процессов технических расчётов, разработки и оптимизации моделей, тестирования сложных технических систем при помощи компьютерного моделирования, и, самое главное - интеграции с внешними инженерными средами.
Для начала работы необходимо прочесть документацию к API Engee. В ней же указано, как получить Personal Access Token (PAT).

Подготовка окружения

При разработке примера работы использована версия Python 3.13.
Установим необходимые зависимости:

In [ ]:
%pip install -r requirements.txt
Requirement already satisfied: requests in c:\users\alexe\appdata\local\programs\python\python313\lib\site-packages (from -r requirements.txt (line 1)) (2.32.5)
Requirement already satisfied: charset_normalizer<4,>=2 in c:\users\alexe\appdata\local\programs\python\python313\lib\site-packages (from requests->-r requirements.txt (line 1)) (3.4.2)
Requirement already satisfied: idna<4,>=2.5 in c:\users\alexe\appdata\local\programs\python\python313\lib\site-packages (from requests->-r requirements.txt (line 1)) (3.10)
Requirement already satisfied: urllib3<3,>=1.21.1 in c:\users\alexe\appdata\local\programs\python\python313\lib\site-packages (from requests->-r requirements.txt (line 1)) (2.2.1)
Requirement already satisfied: certifi>=2017.4.17 in c:\users\alexe\appdata\local\programs\python\python313\lib\site-packages (from requests->-r requirements.txt (line 1)) (2024.12.14)
Note: you may need to restart the kernel to use updated packages.

После получения токена для этого проекта достаточно просто указать его в файле .env, приложенном к проекту. При публикации проекта в публичный репозиторий не забудьте удалить этот файл или добавить его .gitignore, а также указать Ваш PAT в секретах репозитория.

PAT, указанный в .env проекта отредактирован для защиты личных данных.

Реализация EngeeManager

Ниже приведена реализация всех необходимых для работы классов м функций. Дополнительно они снабжены пояснениями, а в сложных для понимания логики местах - комментариями.

In [ ]:
import os
from abc import ABC, abstractmethod
from typing import Optional, Dict, Any, Union
from dataclasses import dataclass
import requests
import json
from enum import Enum
from dotenv import load_dotenv

class ServerStatus(str, Enum):
    RUNNING = "running"
    STOPPED = "stopped"
    STARTING = "starting"

@dataclass
class EngeeConfig:
    """Конфигурация подключения к Engee."""
    pat: str
    base_url: str = "https://engee.com"
    timeout: float = 36000.0  # Таймаут бездействия по умолчанию, сек
    # serverName: str = "demo00000000-engeeid"


class IEngeeAPI(ABC):
    """Абстрактный интерфейс для API Engee."""

    @abstractmethod
    def get_server_info(self) -> Optional[Dict[str, Any]]:
        pass

    @abstractmethod
    def start_server(self) -> Optional[Dict[str, Any]]:
        pass

    @abstractmethod
    def stop_server(self) -> bool:
        pass

    @abstractmethod
    def execute_command(self, command: str) -> Optional[str]:
        pass

    @abstractmethod
    def upload_file(self, destination_path: str, local_path: str) -> bool:
        pass


class EngeeAPI(IEngeeAPI):
    """Реализация API для взаимодействия с Engee."""

    def __init__(self, config: EngeeConfig):
        self.config = config
        self._headers = {"Authorization": f"Bearer {config.pat}"}

    def _make_request(self, method: str, url: str, **kwargs) -> Optional[requests.Response]:
        """Внутренний метод для выполнения HTTP-запросов."""
        try:
            response = requests.request(method, url, headers=self._headers, **kwargs)
            return response if response.status_code < 400 else None
        except requests.exceptions.RequestException as e:
            print(f"Ошибка запроса: {e}")
            return None

    def get_server_info(self) -> Optional[Dict[str, Any]]:
        """Получить информацию о состоянии сервера."""
        info_url = f"{self.config.base_url}/account/api/engee/info"
        response = self._make_request("GET", info_url)
        return response.json() if response else None

    def start_server(self) -> Optional[Dict[str, Any]]:
        """Запустить сервер."""
        info = self.get_server_info()
        if not info or info.get("serverStatus") != ServerStatus.STOPPED:
            print("Сервер не находится в состоянии 'stopped', запуск невозможен.")
            return None

        start_url = f"{self.config.base_url}/account/api/engee/start"
        payload = {
            "url": info["url"],
            "inactivityTimeout": self.config.timeout
        }
        headers = {**self._headers, "Content-Type": "application/json"}

        response = self._make_request("POST", start_url, json=payload)
        return response.json() if response else None

    def stop_server(self) -> bool:
        """Остановить сервер."""
        stop_url = f"{self.config.base_url}/account/api/engee/stop"
        response = self._make_request("DELETE", stop_url)
        success = response is not None and response.status_code == 204
        if success:
            print("Сервер остановлен")
        return success

    def execute_command(self, command: str) -> Optional[str]:
        """Выполнить команду на сервере."""
        info = self.get_server_info()
        if not info:
            print("Не удалось получить информацию о сервере.")
            return None

        if info.get("serverStatus") != ServerStatus.RUNNING:
            status = info.get("serverStatus")
            if status == ServerStatus.STOPPED:
                print("Сервер не запущен. Используйте команду `start_server()` для запуска сервера.")
            else:
                print("Сервер запускается. Повторите попытку позднее.")
            return None

        server_url = f"{self.config.base_url}{info['url']}"
        eval_url = f"{server_url}/external/command/eval"
        payload = {"command": command}
        headers = {**self._headers, "Content-Type": "application/json"}

        response = self._make_request("POST", eval_url, json=payload)
        if response and response.status_code == 201:
            result = response.json().get("result")
            print(result)
            return result
        else:
            print("No result")
            return None

    def upload_file(self, destination_path: str, local_path: str) -> bool:
        """Загрузить файл на сервер."""
        if not os.path.isfile(local_path):
            raise FileNotFoundError(f"Файл не найден: {local_path}")

        info = self.get_server_info()
        if not info:
            print("Не удалось получить информацию о сервере.")
            return False

        if info.get("serverStatus") != ServerStatus.RUNNING:
            status = info.get("serverStatus")
            if status == ServerStatus.STOPPED:
                print("Сервер не запущен. Используйте команду `start_server()` для запуска сервера.")
            else:
                print("Сервер запускается. Повторите попытку позднее.")
            return False

        server_url = f"{self.config.base_url}{info['url']}"
        upload_url = f"{server_url}/external/file/upload"
        filename = os.path.basename(local_path)

        files = {
            'paths': (None, json.dumps({"sources": [destination_path]})),
            'upload_files': (filename, open(local_path, 'rb'))
        }

        try:
            headers = {**self._headers} 
            response = self._make_request("POST", upload_url, files=files)
            if response:
                if response.status_code == 201:
                    print("Успешно загружено")
                    return True
                elif response.status_code == 200:
                    print(f"Файл с таким именем существует. Загруженному файлу присвоено имя {response.text}")
                    return True
                else:
                    print(f"Ошибка: {response.status_code}{response.text}")
            return False
        finally:
            if 'upload_files' in files and hasattr(files['upload_files'][1], 'close'):
                files['upload_files'][1].close()
    
    def download(self, remote_path: Union[str, list[str]], local_save_path: str) -> bool:
        """
        Универсальный метод скачивания.
        :param remote_path: строка (один файл) или список строк (несколько файлов)
        :param local_save_path: локальный путь для сохранения (файл или ZIP)
        :return: True при успехе, False при ошибке
        """

        # Определяем, передан один путь или несколько
        if isinstance(remote_path, str):
            paths_to_download = [remote_path]
            single_file_mode = True
        elif isinstance(remote_path, list):
            paths_to_download = remote_path
            single_file_mode = False
        else:
            print("Ошибка: remote_path должен быть строкой или списком строк.")
            return False

        # Проверяем, что все пути начинаются с '/user/'
        for path in paths_to_download:
            if not path.startswith('/user/'):
                print(f"Ошибка: Все пути должны начинаться с '/user/'. Указан: {path}")
                return False

        info = self.get_server_info()
        if not info:
            print("Не удалось получить информацию о сервере.")
            return False

        if info.get("serverStatus") != ServerStatus.RUNNING:
            status = info.get("serverStatus")
            if status == ServerStatus.STOPPED:
                print("Сервер не запущен. Используйте команду `start_server()` для запуска сервера.")
            else:
                print("Сервер запускается. Повторите попытку позднее.")
            return False

        server_url = f"{self.config.base_url}{info['url']}"
        download_url = f"{server_url}/external/file/download"

        payload = {"sources": paths_to_download}

        try:
            response = self._make_request("POST", download_url, json=payload)
            if not response:
                print("Запрос на скачивание не удался.")
                return False

            if response.status_code == 404:
                print(f"Один или несколько файлов из списка не существуют на сервере: {paths_to_download}")
                return False

            if response.status_code == 403:
                print(f"Один или несколько файлов нарушают правило: должны быть внутри /user/. Проверьте: {paths_to_download}")
                return False

            if response.status_code != 200:
                print(f"Ошибка при скачивании: {response.status_code}{response.text}")
                return False

            # Создаём директорию для сохранения
            os.makedirs(os.path.dirname(local_save_path), exist_ok=True)

            content_type = response.headers.get('Content-Type', '')

            if single_file_mode:
                # Скачиваем как один файл
                with open(local_save_path, 'wb') as f:
                    f.write(response.content)
                print(f"Файл '{remote_path}' успешно скачан в '{local_save_path}'")
            else:
                # Скачиваем как ZIP-архив
                if 'application/x-zip-compressed' not in content_type and 'zip' not in content_type:
                    print(f"Предупреждение: Ответ не является ZIP-архивом. Content-Type: {content_type}")

                with open(local_save_path, 'wb') as f:
                    f.write(response.content)
                print(f"Архив с файлами успешно скачан в '{local_save_path}'")

            return True

        except Exception as e:
            print(f"Ошибка при скачивании: {e}")
            return False

class EngeeManager:
    """Основной класс для управления Engee"""

    def __init__(self, config: EngeeConfig):
        self.api = EngeeAPI(config)

    def get_status(self) -> Optional[ServerStatus]:
        """Получить текущий статус сервера."""
        info = self.api.get_server_info()
        if info:
            status_str = info.get("serverStatus")
            try:
                return ServerStatus(status_str)
            except ValueError:
                return None
        return None

    def start(self) -> Optional[Dict[str, Any]]:
        """Запустить сервер."""
        return self.api.start_server()

    def stop(self) -> bool:
        """Остановить сервер."""
        return self.api.stop_server()

    def command(self, command: str) -> Optional[str]:
        """Выполнить команду на сервере."""
        return self.api.execute_command(command)

    def upload(self, destination_path: str, local_path: str) -> bool:
        """Загрузить файл на сервер."""
        return self.api.upload_file(destination_path, local_path)
    
    def download(self, remote_path: Union[str, list[str]], local_save_path: str) -> bool:
        """
        Универсальный метод скачивания: один файл или несколько.
        :param remote_path: строка или список строк
        :param local_save_path: куда сохранить
        """
        return self.api.download(remote_path, local_save_path)


def load_config_from_env(env_file: str = ".env") -> EngeeConfig:
    """Загрузить конфигурацию из файла .env."""
    try:
        load_dotenv()
    except ImportError:
        print("Библиотека python-dotenv не установлена. Пропускаем загрузку .env.")

    pat = os.environ.get("ENGEE_PAT")
    if not pat:
        raise ValueError("Переменная окружения ENGEE_PAT не найдена.")

    return EngeeConfig(pat=pat)

Пример использования

Загрузим конфигурацию из файла .env и создадим образец класса EngeeManager:

In [ ]:
config = load_config_from_env()
manager = EngeeManager(config)

Engee остановлен, проверим статус:

In [ ]:
print(f"Текущий статус: {manager.get_status()}")
Текущий статус: ServerStatus.STOPPED

Запуск Engee

Запустим Engee и проверим статус:

In [ ]:
manager.start()
print(f"Текущий статус: {manager.get_status()}")
Текущий статус: ServerStatus.STARTING

Пока сеанс не запущен, мы не сможем выполнять команды:

In [ ]:
manager.command("pwd()")
Сервер запускается. Повторите попытку позднее.

Повторная попытка запуска также не даст результатов:

In [ ]:
manager.start()
Сервер не находится в состоянии 'stopped', запуск невозможен.

Выполнение команд

Сразу после запуска сеанса Engee мы можем отправить команды по API для выполнения:

In [ ]:
print(f"Текущий статус: {manager.get_status()}")
pwd = manager.command("pwd()")
engee_version = manager.command("engee.version()")
Текущий статус: ServerStatus.RUNNING
/user
25.12.2-H1

Загрузка файлов

Загрузим файл на сервер:

In [ ]:
upload_status = manager.upload("/user/upload_test/", r'D:\engee_api\upload_test\upload_test.txt')
Файл с таким именем существует. Загруженному файлу присвоено имя {"paths":["/user/upload_test/upload_test.txt"]}

Фукция возвращает логическое значение True при успешной загрузке. Проверим нахождение загруженного файла на сервере:

In [ ]:
files = manager.command('readdir("/user/upload_test/")')
["upload_test.txt"]

Попытаемся повторно загрузить файл с тем же именем:

In [ ]:
upload_status = manager.upload("/user/upload_test/", r'D:\engee_api\upload_test\upload_test.txt')
Файл с таким именем существует. Загруженному файлу присвоено имя {"paths":["/user/upload_test/upload_test_1.txt"]}

Содержимое целевой директории на сервере:

In [ ]:
files = manager.command('readdir("/user/upload_test/")')
["upload_test.txt", "upload_test_1.txt"]

Скачивание файлов

Скачаем один из ранее загруженных файлов в папку на компьютере:

In [ ]:
manager.download("/user/upload_test/upload_test_1.txt", r"D:\engee_api\download_test\download_test.txt")
Файл '/user/upload_test/upload_test_1.txt' успешно скачан в 'D:\engee_api\download_test\download_test.txt'
Out[0]:
True

Попытаемся скачать несколько файлов сразу. Вспоминаем, что ранее в переменную files мы записали список файлов в результирующей директории на сервере. Передадим этот список в соответствующую функцию:

In [ ]:
manager.download(
        files,
        r"D:\engee_api\download_test\download_test.zip"
    )
Ошибка: Все пути должны начинаться с '/user/'. Указан: ["upload_test.txt", "upload_test_1.txt"]
Out[0]:
False

Мы получили ожидаемую ошибку - согласно документации мы должны указывать пути скачиваемых файлов, дочерние /user/.

In [ ]:
manager.download(
        ["/user/upload_test/upload_test.txt", "/user/upload_test/upload_test_1.txt"],
        r"D:\engee_api\download_test\download_test.zip"
    )
Архив с файлами успешно скачан в 'D:\engee_api\download_test\download_test.zip'
Out[0]:
True

Файлы скачаны на компьютер, можно проверить их наличие в папке данного примера.

Остановка Engee

In [ ]:
stop_status = manager.stop()
print(f"Текущий статус: {manager.get_status()}")
Сервер остановлен
Текущий статус: ServerStatus.STOPPED
In [ ]:
pwd = manager.command("pwd()")
Сервер не запущен. Используйте команду `start_server()` для запуска сервера.

Заключение

Мы рассмотрели реализацию класса для работы с внешним API Engee по PAT, а также варианты использования и результаты работы функций для проверки статуса, запуска, остановки Engee, отправки команд и загрузки/скачивания файлов.

Однако это не все имеющиеся даже на текущий релиз возможности API Engee - например, мы не рассмотрели в этом примере Асинхронное исполнение кода. Но это тема, которая заслуживает отдельного и подробного рассмотрения.