Engee documentation
Notebook

Working with the Engee external API (Personal Access Token)

In the example, we consider working with the external Engee API using PAT (Personal Access Token) - the EngeeManager control class is implemented, the functions of checking the status, starting, stopping, executing commands, uploading and downloading files.

This example does not consider asynchronous code execution, as well as the implementation of access to the Engee API using OAuth 2.0.

Introduction

Working with Engee through an external API opens up even broader possibilities for automating technical calculations, developing and optimizing models, testing complex technical systems using computer modeling, and, most importantly, integrating with external engineering environments.
To get started, you need to read документацию to the Engee API. It also tells you how to get a Personal Access Token (PAT).

Environment preparation

When developing the work example, Python version 3.13 was used.
Install the necessary dependencies:

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.

After receiving the token for this project, it is enough to simply specify it in the file. .env attached to the project. When publishing a project to a public repository, don't forget to delete this file or add it. .gitignore, and also specify your PAT in the repository secrets.

The PAT specified in the project's .env has been edited to protect personal data.

EngeeManager implementation

Below is the implementation of all the necessary classes and functions. Additionally, they are provided with explanations, and in difficult-to-understand-logic places - with comments.

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:
    """Configuration of the connection to Engee."""
    pat: str
    base_url: str = "https://engee.com"
    timeout: float = 36000.0  # Default idle timeout, seconds
    # serverName: str = "demo00000000-engeeid"


class IEngeeAPI(ABC):
    """An abstract interface for the Engee API."""

    @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):
    """Implementation of an API for interacting with 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]:
        """An internal method for executing HTTP requests."""
        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"Request error: {e}")
            return None

    def get_server_info(self) -> Optional[Dict[str, Any]]:
        """Get information about the server status."""
        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]]:
        """Start the server."""
        info = self.get_server_info()
        if not info or info.get("serverStatus") != ServerStatus.STOPPED:
            print("The server is not in the 'stopped' state, startup is not possible.")
            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 the server."""
        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("The server is stopped")
        return success

    def execute_command(self, command: str) -> Optional[str]:
        """Run the command on the server."""
        info = self.get_server_info()
        if not info:
            print("Couldn't get information about the server.")
            return None

        if info.get("serverStatus") != ServerStatus.RUNNING:
            status = info.get("serverStatus")
            if status == ServerStatus.STOPPED:
                print("The server is not running. Use the `start_server()` command to start the server.")
            else:
                print("The server is starting. Please try again later.")
            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:
        """Upload the file to the server."""
        if not os.path.isfile(local_path):
            raise FileNotFoundError(f"File not found: {local_path}")

        info = self.get_server_info()
        if not info:
            print("Couldn't get information about the server.")
            return False

        if info.get("serverStatus") != ServerStatus.RUNNING:
            status = info.get("serverStatus")
            if status == ServerStatus.STOPPED:
                print("The server is not running. Use the `start_server()` command to start the server.")
            else:
                print("The server is starting. Please try again later.")
            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("Uploaded successfully")
                    return True
                elif response.status_code == 200:
                    print(f"A file with that name exists. The uploaded file has been named {response.text}")
                    return True
                else:
                    print(f"Error: {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:
        """
        A universal download method.
        :param remote_path: string (single file) or list of strings (multiple files)
        :param local_save_path: local path to save (file or ZIP)
        :return: True on success, False on error
        """

        # Determining whether one path has been passed or several
        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("Error: remote_path must be a string or a list of strings.")
            return False

        # We check that all paths start with '/user/'
        for path in paths_to_download:
            if not path.startswith('/user/'):
                print(f"Error: All paths must start with '/user/'. Specified: {path}")
                return False

        info = self.get_server_info()
        if not info:
            print("Couldn't get information about the server.")
            return False

        if info.get("serverStatus") != ServerStatus.RUNNING:
            status = info.get("serverStatus")
            if status == ServerStatus.STOPPED:
                print("The server is not running. Use the `start_server()` command to start the server.")
            else:
                print("The server is starting up. Please try again later.")
            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("The download request failed.")
                return False

            if response.status_code == 404:
                print(f"One or more files from the list do not exist on the server: {paths_to_download}")
                return False

            if response.status_code == 403:
                print(f"One or more files violate the rule: they must be inside /user/. Check: {paths_to_download}")
                return False

            if response.status_code != 200:
                print(f"Error when downloading: {response.status_code}{response.text}")
                return False

            # Creating a directory to save
            os.makedirs(os.path.dirname(local_save_path), exist_ok=True)

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

            if single_file_mode:
                # Download as a single file
                with open(local_save_path, 'wb') as f:
                    f.write(response.content)
                print(f"File '{remote_path}' successfully downloaded to '{local_save_path}'")
            else:
                # Download as a ZIP archive
                if 'application/x-zip-compressed' not in content_type and 'zip' not in content_type:
                    print(f"Warning: The response is not a ZIP archive. Content-Type: {content_type}")

                with open(local_save_path, 'wb') as f:
                    f.write(response.content)
                print(f"Archive with files successfully downloaded to '{local_save_path}'")

            return True

        except Exception as e:
            print(f"Error when downloading: {e}")
            return False

class EngeeManager:
    """The main class for managing Engee"""

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

    def get_status(self) -> Optional[ServerStatus]:
        """Get the current server status."""
        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]]:
        """Start the server."""
        return self.api.start_server()

    def stop(self) -> bool:
        """Stop the server."""
        return self.api.stop_server()

    def command(self, command: str) -> Optional[str]:
        """Run the command on the server."""
        return self.api.execute_command(command)

    def upload(self, destination_path: str, local_path: str) -> bool:
        """Upload the file to the server."""
        return self.api.upload_file(destination_path, local_path)
    
    def download(self, remote_path: Union[str, list[str]], local_save_path: str) -> bool:
        """
        Universal download method: one file or several.
        :param remote_path: string or list of strings
        :param local_save_path: where to save
        """
        return self.api.download(remote_path, local_save_path)


def load_config_from_env(env_file: str = ".env") -> EngeeConfig:
    """Download the configuration from the .env file."""
    try:
        load_dotenv()
    except ImportError:
        print("The python-dotenv library is not installed. Skipping the download of .env.")

    pat = os.environ.get("ENGEE_PAT")
    if not pat:
        raise ValueError("The ENGEE_PAT environment variable was not found.")

    return EngeeConfig(pat=pat)

Usage example

Download the configuration from the file .env and create a sample class EngeeManager:

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

Engee is stopped, let's check the status:

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

Engee Launch

Let's launch Engee and check the status:

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

While the session is not running, we will not be able to execute commands.:

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

Trying to run it again will also not give results.:

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

Executing commands

Immediately after starting the Engee session, we can send commands via the API to execute:

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

Uploading files

Upload the file to the server:

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

The function returns a boolean value of True upon successful loading. Let's check if the uploaded file is on the server.:

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

We will try to re-upload the file with the same name.:

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"]}

Contents of the target directory on the server:

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

Downloading files

Download one of the previously uploaded files to a folder on your computer:

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

Let's try to download several files at once. We recall that earlier in the variable files we have recorded a list of files in the resulting directory on the server. Let's pass this list to the appropriate function.:

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

We received the expected error - according to the documentation, we must specify the paths of the downloaded files, child /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

The files are downloaded to the computer, you can check their presence in the folder of this example.

Engee Stop

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

Conclusion

We have reviewed the implementation of a class for working with the external Engee API via PAT, as well as the use cases and results of functions for checking the status, starting, stopping Engee, sending commands, and uploading/downloading files.

However, these are not all the features of the Engee API available even for the current release - for example, we did not consider [Asynchronous execution] in this example. кода](https://engee.com/helpcenter/stable/ru/external-software/external-software-interface-for-engee.html#асинхронное-исполнение-кода). But this is a topic that deserves a separate and detailed consideration.