Metadata-Version: 2.4
Name: ab_engine
Version: 0.1.5
Summary: Library of standard modules
Author-email: Andrew Bulatov <BulatovAndrew@gmail.com>
License-Expression: MIT
Project-URL: Repository, https://github.com/a-bulatov/ab_engine
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: json5>=0.12.0
Requires-Dist: psycopg>=3.2.9
Requires-Dist: pyaml>=25.7.0
Requires-Dist: toml>=0.10.2

Библиотека стандартных модулей
==========================================================

Позволяет не делать каждый раз, для каждого приложения, такие типовые вещи как:

* [управление настройками](#управление-настройками)
* [управление таймерами](#управление-таймерами)
* [обработка ошибок с возможностью легко поменять текст или язык сообщений](#обработка-ошибок)
* [вывод сообщений в лог](#вывод-сообщений-в-лог)
* [доступ к БД и безопасное выполнение параметризованных запросов](#доступ-к-бд)
* [выполнение операций БД в рамках контекста](#окружение-и-контекст-для-работы-с-бд-db_env)
* [работа с курсорами таблиц БД](#работа-с-курсорами-таблиц-бд)
* [единообразное выполнение функций python и sql](#единообразное-выполнение-функций-python-и-sql)
* [поддержка плагинов](#поддержка-плагинов)

## Управление настройками

Для управления настройками используется класс Config, я вляющийся синглтоном.
В конструктор Config передаются следующие параметры:

**path** - строка с именем файла, либо список строк с именами файлов, содержащих настройки
система определяет тип файла по расширению и загружает его
допустимы следующие расширения: .json, .json5, .yaml, .yml, .toml
в случае, если указан список файлов, каждый последующий файл меняет или дополняет настройки, загруженные из предыдущего.
результатом загрузки является словарь в памяти, который используется в дальнейшем, как источник настроек.
кроме того, в path можно передать словарь, который Config будет воспринимать как набор уже прочитанных настроек

**can_include** - список ключей словаря, значения которых могут быть прочитаны из включаемого файла (include).
если значение такого ключа является строкой, то система считает эту строку путем к включаемому файлу.

**env_map** - задает соответствие ключей словаря переменным окружения.  если такое соответствие заддано, то после
загрузки параметров из файлов, система заменяет значения в словаре значениями из переменныых окружения.

Если в окружении нет таких переменных, то в качестве значения остается, то значение, которое было загружено из файла.
Структура для задания соответствия переменным окружения является словарем вида:

```json
{
  "имя ключа верхнего уровня": {
    "имя переменной окружения": "имя ключа, значение которого заменить из окружения"
    ...
  }
  ...
}
```

### Пример использования конфигурации

создадим файл настроек test.toml:

```toml
[auth]
user = "123" # id пользователя по умолчанию
```

и тестовый пример:

```python
import asyncio
from ab_engine import Config

async def test():
    Config("test.toml", env_map={"auth":{"USER":"user"}})
    Config().log(Config().auth['user'])

if __name__ == '__main__':
    asyncio.run(test())
```

в результате выполнения данной программы в лог будет выведено имя текущего пользователя, либо 123,
если в системе нет переменной окружения USER :)

вместо toml файла можно было использовать yaml:

```yaml
auth:
  user: 123 # id пользователя по умолчанию
```

или json:

```json
{
  "auth": {
    "user": 123
  }
}
```

или json5:

```json5
{
  auth: {
    user: 123 // id пользователя по умолчанию
  }
}
```

### Хранение конфигурации в БД

Для считывания конфигурации из БД, в конструктор Config следует передать не имя файла, а строку соединения: 
```python
import asyncio
from ab_engine import Config

async def test():
    cfg=Config()
    cfg.log(cfg.database)

if __name__ == '__main__':
    Config('postgresql://localhost:5432/test?user=postgres&password=postgres{"query":"select k, v, id, parent_id from system.config"}')
    asyncio.run(test())
```
**ВАЖНО!!** Конструктор Config, читающий из БД должен быть вызван **до** запуска асинхронного цикла.

В параметрах соединения можно задать (внутри фигурных скобок, одной строкой без переносов, маленькими буквами)
query - запрос для получения настроек, в запросе должны быть указаны поля в следующем порядке:
1. поле ключа
2. поле значения
3. идентификатор записи
4. идентификатор родительской записи

также возможен вариант, когда
4. тип значения
5. идентификатор родительской записи

в этом случае конфигуратор понимает следующие типы:
* I - int
* F - float
* S - str
* B - bool

Преобразование типа происходит в функции Config._cast(value, to_type) т.е. наследники Config могут обрабатывать
другие типы.

Если в запросе указано where, то это условие будет добавлено во все запросы
при построении конфигурации.

По умолчанию запрос:
select k, v, id, parent_id from config

соответственно самый простой вариант таблицы:
create table config(
    id serial not null primary key,
    parent_id integer references config(id),
    k varchar(150) not null,
    v varchar,
    constraint config_level_uk unique (parent_id, k)
)

### Специальные ключи конфигурации

нресколько имен ключей файла конфигурации зарезервированы и являются специальными:

**timers**  - определяет интервалы для таймеров системы
**logging** - позволяет задать настройки логирования
**database**- содержит строки соединения с одной или несколькими БД
**defaults**- содержит значения по умолчанию

#### Значения по умолчанию

**defaults.name** - имя, подставляемое в лог как имя сервиса
**defaults.database** - имя соединения БД по умолчанию
**defaults.plugin_path** - путь к каталогу плагинов
**defaults.db_driver_path** - путь к драйверам БД (по умолчанию ab_engine/db)

## Управление таймерами

Таймеры определяются в секции timers файла конфигурации. Для управления таймеры доступны в свойстве Config().timers

Чтобы интервалы использовались в программе, таймеры должны быть проинициализированы методом Config().timers["имя таймера"].init:
init(callback, interval_type = TimerInterval.SECOND, interval: int = 0, immediately_start=True)

> callbac       - обработчик таймера, может быть как async, так и синхронным
> 
> interval_type - тип интервала (единица времени) MILLISECOND, SECOND, MINUTE, HOUR, DAY по умолчанию SECOND
> 
> interval      - количество единиц времени, если задан, то перекроет считанное из конфига
> 
> immediately_start - означает, что calback будет запущен сразу после инициализации, иначе через интервал

например:
Config().timers["X"].init(callback, interval_type = TimerInterval.MINUTE):

callbac должен быть функцией вида:

> def callback(name, prev_time):
> 
> ...

или:

> async def callback(name, prev_time):
> 
> ...

в параметр **name** передается в имя таймера, в **prev_time** предыдущее время запуска, первый раз **prev_time = None**

Предусмотрены следующие типы интервалов работы таймера:

* TimerInterval.MILLISECOND
* TimerInterval.SECOND
* TimerInterval.MINUTE
* TimerInterval.HOUR
* TimerInterval.DAY

Для управления таймерами, также, используются:

* **start**(immediately_start=True) - запускает таймер, если он остановлен. Если immediately_start=False, то таймер ждет свой
    интервал и только потом запускает callback, иначе callback будет запущен сразу после вызова start()
* **stop**()  - останавливает таймер. Остановленный таймер можно запустить методом start()
* **started** - возвращает True если таймер запущен

### Пример работы с таймерами

Создадим файл настроек test.toml:

```toml
[timers]
A = 1
B = 15
```

и программу:

```python
import asyncio
from ab_engine import Config

def timer_test(name, prev_time):
    cfg = Config()
    cfg.log(f"timer test {name} {prev_time}")
    if name == "A":
        if cfg.timers["B"].started:
            cfg.log("STOP timer B")
            cfg.timers["B"].stop()
        else:
            cfg.log("START timer B")
            cfg.timers["B"].start()
  
async def timers():
    Config("test.toml")
    Config().timers["A"].init(timer_test, "minute", immediately_start=False)
    Config().timers["B"].init(timer_test)
    Config().timers["A"].start(immediately_start=False)
    while True:
        await asyncio.sleep(1)


if __name__ == '__main__':
    asyncio.run(timers())
```

В результате таймер A будет вызываться раз в 1 минуту, а таймер В раз в 15 секунд (по умолчанию тип интервала - секунды)
При этом, таймер А будет вызван только через минуту после старта и будет включать/выключать таймер B

## Обработка ошибок

Библиотека предлагает хранение текстов и кодов ошибок в отдельном файле yaml, который имеет следующую структуру:

```yaml
- code: ERROR # код для вызова из функции raise_error
  http: 444 # код для возврата для HTTP. можно не указывать
  msg: В {place} у объекта {name} случилось {what} # Сообщение в которое будут подставлены kwargs из raise_error
  class: Error # задает класс исключения. По умолчанию это Error из библиотеки (т.е. Error можно было нре указывать)
- code: OTHER_ERROR
  class: Exception 
  msg: Еще ошибка
```

сохраним этот yaml как errors.yaml и в файле рядом напишем код:

```python
from ab_engine import error, load_errors

load_errors("./errors.yaml")

try:
    raise error("ERROR", place="программе", name="в коде", what="страшное")
except Exception as e:
    print(type(e))
    print(e)
    print("CODE:", e.code)
    print("HTTP CODE:",e.http_code)

try:
    raise error("OTHER_ERROR")
except Exception as e:
    print(type(e))
    print(e)
```

При выполнении этого кода, сначала получим исключение с типом Error в свойствах которого можно увидеть код ошибки и код http,
а затем получим ошибку с классом Exception и сообщением, считанным из файла

При выполнении load_errors, если в систему уже загружены описания ошибок с кодами, которые есть в загруженном файле, то
описания ошибок будут заменены новыми описаниями.

Если при вызове raise_error не передать какие-либо параметры, которые есть в шаблоне сообщения об ошибке, то вместо них 
в шаблон будут вставлены пустые строки.

## Вывод сообщений в лог

Библиотека предоставляет возможность хранения настроек логирования в файле конфигурации и применение этих настроек
при создании экземпляра класса Config.
Настройки логирования задаются в секции logging. Ниже приведен пример настроек в нотации yaml c комментариями:

```yaml
logging:
  level: debug # уровень лога
  format: [%(asctime)s | %(levelname)s]: %(message)s # формат лога
  datefmt: %m.%d.%Y %H:%M:%S # формат даты в логе

  # секция handler позволяет явно указать класс и параметры логера
  handler:
    class: TimedRotatingFileHandler
    # рядом с именем класса можно указать значения параметров для передачи в конструктор
    # если параметр нужно пеедать определенным типом, например как str, как int или float, то требуемый тип нужно указать после имени
    # в скобках без пробелов, например, iunterval(int)
    filename: /home/abulatov/logs/log.log
    when: m
    interval(int): 1
    backupCount(int): 5
    # если нужно назначить какие-то свойства логгера после создания его экземпляра - используется секция property
    # в свойствах, как  и в параметрах конструктора можно указать тип
    property:
      suffix: "%Y%m%d"
```

Таким образом, библиотека позволяет использовать любой handler реализованный для стандартной библиотеки logging.
Однако уровни лога несколько отличаются от logging и задаются в перечислении ab_engine.LogLevel.
Уровни лога:

* OFF      - логирование выключено
* CRITICAL - выводятся только критические ошибки
* FATAL    - фатальные ошибки (то же, что CRITICAL)
* ERROR    - ошибки
* WARNING  - предупрежения
* WARN     - то же, что WARNING
* INFO     - информационные сообщения
* DEBUG    - отладочные сообщения
* SQL      - то же что DEBUG, но в лог добавляются тексты sql-запросов
* NOTSET   - не задан = в лог выводится всё

Для вывода в лог используется функция Config().log:

```python
    def log(self, msg, level:LogLevel | str | int=LogLevel.INFO, *args, **kwargs):
        """
        запись в лог
        :param msg: сообщение об ошибке. может быть экземпляром Exception
        :param level: уровень сообщения, для msg = Exception автомаптически вставляется ERROR. Может быть передан как строка
        :param args:  --/- зарезервировано для дальнейшего использования
        :param kwargs:-/
        """
```

## Доступ к БД

Библиотека поддерживает хранение настроек соединений и выполнение команд SQL на этих соединениях с БД.
Из коробки реализованы драйвер Postgresql, Sqlite, Mysql (но надо установить зависимость mysql-connector-python)

Можно описывать драйвера к другим БД. Для того чтобы использовать собственный драйвер, можно добавить класс драйвера
в словарь DRIVER_CLASSES, который находится в модуле ab_engine/db/option, либо положить драйвер в каталог драйверов.
Пример драйвера в DRIVER_CLASSES:

необходимости использовать собственный драйвер, можно в строке соединения в 
фигурных скобках задать параметр **driver_path**, определяющий путь для поиска драйвера.

Если нужно задать свой путь поиска для всех драйверов БД, можно задать параметр db_driver_path в defaults конфигурации.

Если приложению требуется только одна строка соединения, то ее можно задать в переменной database, например:

```yaml
database: postgresql://localhost:5432/postgres?user=postgres&password=postgres
```

Также можно задать несколько соединений, если использовать database как секцию:

```toml
[database]
test = "postgresql://localhost:5432/test?user=postgres&password=postgres"
mydb = "postgresql://localhost:5432/mydb?user=postgres&password=postgres"

[defaults]
database = "test"
```

При этом, в секции defaults можно задать имя соединения БД, которое будет использоваться по умолчанию (см пример выше)

Строка соединения с БД имеет следующую структуру:
<имя_драйвера>**://**<адрес сервера>**:**<номер порта>**/**<имя БД>**?**<параметры соединения>**{**дополнительные параметры**}**

* **параметры соединения**  - могут зависеть от СУБД и параметров соединения, поддерживаемых драйвером. 
                          для большинства БД нужны такие параметры как **user** и **password**.
                          помимо параметров, определяемых драйвером, можно передать параметр driver_path, который 
                          определяет путь поиска драйвера

* **дополнительные параметры**  - параметры, которые будут переданы в сессию соединения с БД (в фигурных скобках). 
    передаются как **"**ключ**"="**значение**"**
    в качестве разделителя параметров используется запятая. 
    Для любого сочетания драйвер+строка соединения-доп.параметры, можно задать дополнительный параметр LIMIT, 
    который ограничит максимальное количество таких соединений. Т.е. по достижении данного числа соединений система
    не станет выполнять очередную команду до тех пор, пока не освободится одно из текущих соединений с БД.

Драйвер БД должен иметь имя driver_<имя в строке соединения>.py и предоставлять следующий контракт:

```python
class Driver:

    @abstractmethod
    def __init__(self, connection_string, on_open_close=None):
        """
        констркуктор драйвера.
        должен разобрать строку соединения (описана выше) и инициализировать экземпляр драйвера.
        дополнительные параметры строки соединения (те что в фигурных скобках) не передаются в строку 
        соединения.
        on_open_close функция, передаваемая драйверу, описываемая как 
        async def on_open_close(self, close=False)->dict:
        данная функция возвращает драйверу дополнительные параметры, которые могут быть использованы 
        при создании и закрытии соединения (если драйвер это поддерживает) 
        """
        ...

    @property
    def in_transaction(self)->bool:
        """
        сообщает открыта ли сейчаст транзакция на соединении поддерживаемом экземпляром драйвера
        """
        ...

    @abstractmethod
    async def begin(self):
        """
        может быть вызвана методом sql для создания соединения с БД, если это соединение не открыто
        тут же могут быть использованы параметры, возвращаемые функцией on_open_close
        """
        #self._conn = None
        ...

    @abstractmethod
    async def sql(self, query, one_row=False, row_factory=RowFactory.DICT):
        """
        выполняет запрос к БД
        query - текст запроса
        one_row - переключатель метода получения запроса между feth и fethall
        row_factory - задает фабрику для получения данных драйвером.
            перечисление RowFactory допускает следующие значения:
                ANY   - значение, используемое чтобы показать, что фабрика не задана
                TUPLE - возврат значений строки в tuple
                DICT  - возвращает словарь, где ключи имена полей, а значения - значения полей
                NAMED_TUPLE - возвращает строки как namedtuple
        Для выборки возвращает результат с применением соответствующего RowFactory, либо [], 
        если one_row=False и выборка пуста
        Если one_row=True, то вернет только первую строку результата, либо Null, 
        если соответствующая запись не найдена.
        В случае, если query является запросом, не возвращающим выборку, вернет 
        целое число - количество записей, на которые повлиял запрос.
        """
        #if self._conn is None:
        #    await self.begin()
        ...

    @abstractmethod
    async def commit(self):
        """
        подтверждает выполнение транзакции и закрывает соединение
        """
        ...

    @abstractmethod
    async def rollback(self):
        """
        отменяет выполнение транзакции и закрывает соединение
        """
        ...
    
    @abstractmethod
    async def table_struct(self, table_name)->dict:
        """
        возвращает структуру заданной таблицы в формате:
        {
            "table": "имя таблицы",
            "description": "описание, если есть",
            "fields": [
                {
                    "name": "имя поля для использования в python",
                    "field": "имя поля для использования в запросах (например в двойных кавычках)",
                    "type": "имя типа поля из БД",
                    "python_type": "тип python для хранения поля",
                    "size": "размер поля",
                    "not_null": "True, если значение не может быть null"
                }
            ],
            "constraints": [    
                {
                    "type": "primary key" | "unique",
                    "fields": ["список полей"]
                },
                {
                    "type": "foreign key",
                    "table": "имя таблицы, на которую ссылка",
                    "fields": {
                      "поле лакальной таблицы": "поле таблицы по ссылке"
                    }
                }
            ]
        }
        """
        ...

    async def parse_query(self, query, *args, **kwargs):
        """подставляет параметры в текст запроса"""
        ...

    async def parse_func(self, func_name, *args, **kwargs):
        """
        Возвращает запрос для вызова функции func_name
        :param args: неименованные параметры
        :param kwargs: именованные параметры
        :return: запрос
        """
        ...
    
    async def cast(self, param_name, to_type):
        """
        возвращает часть запроса для приведения типа параметра с заданным именем
        """
        ...

    async def page(self, query, limit, offset):
        """
        возвращает запроса query с дописанными в него ограничением количества записей и отступом 
        от начала выборки
        """
        ...
```
Класс Driver, как и перечисление RowFactory можно найти в ab_engine/db/driver.py
Функции cast, parse_func и parse_query уже реализованы в Driver и могут быть повторно использованы.

Функция **cast** принимает на вход имя параметра и имя типа, к которому параметр нужно привести.
Синтаксис приведения параметра к типу зависит от СУБД, данная функция позволяет унифицировать преобразование типов.

Функция **parse_func** позволяет унифицировать вызов функций. Подставляет необходимый для вызова префикс и вызывает
parse_query для подстановки параметров.

Функция **parse_query** обеспечивает безопасную подстановку значений параметров в запрос. Для того чтобы указать
в тексте запроса параметр, следует указать $<номер параметра> и тогда запрос будет взят из args с номером на единицу меньшим.
Либо указать $<имя параметра> в этом случае значение будет взято из kwargs с тем же именем. Номер всегда состоит только из цифр.
Имя начинается с буквы и заканчивается перед пробельным символом или перед скобкой, или двоеточием, или запятой. 
В примере ниже все параметры имеют имя **name**:
```sql
select $name, t.value from t;

select t.id from t where t.name = $name and t.ok;

select t.x 
from t
inner join t2 on t2.t_id=t.id and t2.name=$name
where t.date < now()::date;
```
система автоматически создает подстроку со значением параметра и, при необходимости, заключает это значение в кавычки.
|**Тип python**|**Тип БД**|**В кавычках**|
|--------|---------|-----|
| None   | NULL    | [ ] |
| str    | varchar | [x] |
| int    | bigint  | [ ] |
| float  | double precision  | [ ] |
| dict   | json    | [ ] |
| list, tuple | array*  | [x*] |
| set    | array   | [x] |
| date   | date    | [x] |
| time   | timestamp| [x] |

\* если указано преобразование в json ( $x::json ), то список будет преобразован в json, а не в массив

В системе есть несколько способов выполнения sql запросов:
* функция sql из модуля ab_engine.db
* функция sql, как функция член класса DB_ENV из модуля ab_engine.env
* sql запрос, зарегистрированный как метод RPC

### Функция sql из модуля ab_engine.db
Пример:
```python main.py
import asyncio
from ab_engine.db import sql, DB, ONE

async def main():
    x = await sql("SELECT sqlite_version()", DB("sqlite://:memory:"), ONE)
    print(x)

if __name__ == '__main__':
    asyncio.run(main())
```
Это минимальный пример, в котором для вызова sql-запроса не требуется создавать файл конфигурации.
Для передачи строки соединения с БД используется опция DB (об опциях см. ниже) 
Если же убрать строку соединения в файл конфигурации, например, в config.yaml :
```yaml config.yaml
database: postgresql://localhost:5432/postgres?user=postgres&password=postgres
```
то код на python, аналогичный примеру выше, будет выглядеть так:
```python main.py
import asyncio
from ab_engine import Config
from ab_engine.db import sql, ONE

async def main():
    Config("config.yaml")
    x = await sql("select version()", ONE)
    print(x)

if __name__ == '__main__':
    asyncio.run(main())
```
В этом примере стало необязательно указывать опцию DB, тк если она не указана, то система берет ту строку соединения,
которая указана в файле конфигурации.

Для выполнения запроса, в обоих примерах, мы использовали функцию sql из ab_engine.db, которая определяется как:
```python
async def sql(query:str, *args, **kwargs):
    ...
```
На вход эта функция принимает текст запроса, позиционные параметры, опции и именованные параметры.

Опции, это специальные параметры, которые влияют на выполнение запроса в целом.
Передавать опции можно только в конце списка args, после позиционных параметров, либо вместо них.
В библиотеке реализованы следующие опции:
* DB    - служит для передачи соединения в функцию sql. Передается как DB("строка соединения").
* TIMEOUT- устанавливает максимальное время в секундах на выполнение запроса. точность - до сотых секунды. 
        По умолчанию вызывает ошибку, при превышении времени ожидания. TIMEOUT(time_sec:float, raise_error:bool=True)
* DICT   - Указывает, что результат следует вернуть как список dict (RowFactory.DICT). Установлена по умолчанию
* TUPLE  - Указывает, что результат следует вернуть как список tuple (RowFactory.TUPLE)
* OBJECT - Указывает, что результат следует вернуть как список namedtuple (RowFactory.NAMED_TUPLE)
* ROW    - Указывает, что нужно вернуть только первую строку из выборки
* ONE    - Указывает, что нужно вернуть только значение первого поля из первой строки выборки
* JSON   - Указывает, что значение первого поля в первой строке следует привести к dict или list
* ROLLBACK - указывает, что после выполнения запроса соединение должно быть закрыто с откатом транзакции
* COMMIT - Указывает, что после выполнения запроса соединение должно быть закрыто с подтверждением транзакции
* PAGE   - Позволяет задать максимальный размер страницы и её смещение от начала выборки (limit, offset)
* ITERATOR - Если опция задана, то результатом функции sql будет итератор для работы с большими таблицами в async for
* ALL    - Специальная опция, добавлена для принудительного отключения фильтров в функциях, которые это понимают (например у экземпляра Table)
* RAW    - Отключает предобработку запроса с подстановкой параметров
* CALLBACK - Из функции sql отдает в callback_function запрос с подставленными параметрами. Что позволяет доработать запрос или вывести в лог.
        Eсли callback_function возвращает строку, то именно эта строка станет запросом. Функция sql вызывает только первый callback из args

CALLBACK, TIMEOUT и PAGE это опции, в которых нужно вызывать конструктор. 
В опциях DB и ITERATOR можно вызвать конструктор дял передачи доп. параметров этих опций.
Другие опции можно указывать без вызова конструктора (Без круглых скобок после имени опции).

Соединение, созданное в опции DB можно использовать в нескольких запросах:
```python main.py
import asyncio
from ab_engine.db import sql, DB, ONE, ROLLBACK, COMMIT, TIMEOUT

async def main():
    db = DB("postgresql://localhost:5432/postgres?user=postgres&password=postgres")
    x = await sql("select version()", db, ONE, TIMEOUT(0.5))
    print(x)
    x = await sql("select '{1,2,3}'::bigint[]", db, ROLLBACK) # в этом месте соединение закроется с откатом
    print(x)
    await sql("create table if not exists t1(id serial primary key, v varchar)", db) # тут снова откроется соединение с теми же параметрами
    await sql("insert into t1(v) values ('test')", db, COMMIT) # тут соединение закроется с COMMIT двух последних запросов
    
if __name__ == '__main__':
    asyncio.run(main())
```

Опцию TIMEOUT можно использовать для вызова любых асинхронных функций:
```python main.py
import asyncio
from ab_engine.db import TIMEOUT

async def main():
    await TIMEOUT(2)(asyncio.sleep(5))
    print("OK!")
    
if __name__ == '__main__':
    asyncio.run(main())
```

## Окружение и контекст для работы с БД (DB_ENV)

DB_ENV и его наследники предоставляют окружение, использующую и используемую различными компонентами библиотеки.
Такое окружение предполагает обязательную настройку параметров с использованием конфигурационных файлов и переменных окружения ОС.
Класс DB_ENV находится в модуле ab_engine.env и позволяет:
* хранить переменные локального окружения
* управлять состоянием транзакции
* выполнять запросы в контексте и вне его
* генерировать курсоры для таблиц БД

DB_ENV реализует следующий контракт:
```python
class DB_ENV:

    def __init__(self, connection: str="", db_params:Optional[set]=None, **kwargs):
        """
        Окружение для работы с БД
        :param connection: строка соединения или экземпляр DB_ENV, на основе которого нужно создать данный
        :param db_params: список переменных, которые должны передаваться в окружение соединения с БД
        :param kwargs: значения переменных
        """
        
    def has_item(self, key):
        """
        Возвращает True если в данном DB_ENV хранится переменная с именем key
        Переменные передаются в kwargs, либо экземпляру DB_ENV оператором [], например:
        db = DB_ENV()
        db["x"] = 1
        print(db["x"])
        """

    @property
    def connection_string(self):
        # возвращает строку соединения
        ...

    def on_commit(self, callback:Callable, subscribe=True):
        """
        Позволяет добавить или удалить обработчик события подтверждения транзакции
        :param callback: функция, в которую первым атрибутом передается cсылка на окружение
                        если функция вернет False, то commit будет отменен и транзакция откатится (rollback)
        :param subscribe: True указывает, что данный обработчик подписывается на событие (по умолчанию)
        """

    async def sql(self, query, *args, **kwargs):
        # выполняет команду sql. если на момент вызова соединение с БД (транзакция) закрыто, то создает новое соединение
        ...

    @property
    def in_transaction(self):
        """
        Возвращает True если в данном DB_ENV открыта транзакция
        Окружение долускает автоматическое управление транзакцией в контексте, вида:
        async with DB_ENV() as db:
            db.sql...
        При успешном завершении всех операторов в контексте автоматически происходит commit, иначе rollback
        """

    async def commit(self):
        # подтверждает транзакцию и закрывает соединение с БД
        ...

    async def rollback(self):
        # откатывает транзакцию и закрывает соединение с БД
        ...

    @staticmethod
    async def garbage_collect():
        """
        сборка мусора и закрытие подвисших соединений
        """
        ...
```
Библиотека предусматривает возможность использования getter и setter для переменных окружения.
Для этого в модуле ab_engine.env есть класс Property, конструктор которого принимает параметры getter и setter.
Если передать экземпляр Property в переменные экземпляра DB_ENV, то для получения и установки значения переменной будут 
использованы функции переданные параметрах конструктора Property.
Например:
```python
from ab_engine import Config
from ab_engine.env import DB_ENV, Property

X = 0

def get():
    return X*2

def set(V):
    global X
    X = V

Config("config.yaml")
db = DB_ENV(x=Property(get, set), y=1)
print(db["x"], db["y"]) # напечатает 0 1
db["x"] = 1
print(db["x"]) # напечатает 2
```

### Вызов запросов из DB_ENV

Для удобства работы DB_ENV предлагает более высокоуровневый вариант функции sql, а также возможность выполнения sql 
команд в контексте (with), где команды, выполняемые в контексте, выполняются в одной транзакции, которая автоматически
подтверждается в случае успешного выполнения команд, либо откатывается в случае ошибки.

```python
import asyncio
from ab_engine.db import ONE
from ab_engine import Config
from ab_engine.env import DB_ENV


async def main():
    Config("pyproject.toml")
    async with DB_ENV() as env:        
        await env.sql("create table if not exists t1(id serial primary key, v varchar)")
        await env.sql("insert into t1(v) values ('from env')")
        x = await env.sql("select version()", ONE)
        print(x)

if __name__ == '__main__':
    asyncio.run(main())
```
В этом примере видно, что окружение получает информацию о строке соединения из конфигурации и само управляет
транзакцией, применяя COMMIT при закрытии контекста. Если же допустить ошибку, например убрав одну из скобок в version(),
контекст завершит транзакцию и откатит её, т.е. результат выполнения insert не будет сохранен.
В данном случае DB_ENV создан без параметров, но он может принять на вход:
* connection - строку соединения с БД. это позволяет задать соединение отличное от заданного файла конфигурации. но, 
  тем не менее, окружение не может работать без конфигурации потому что требует и других настроек, например настроек ведения лога.
* params - словарь с дополнительными параметрами соединения. например, тут можно передать id текущего пользователя или 
 еще какие-то параметры сессии.

Окружение позволяет работать и без with, что более гибко т.к. на одном экземпляре окружения можно последовательно
создать и завершить несколько транзакций:
```python
import asyncio
from ab_engine.db import ONE
from ab_engine import Config
from ab_engine.env import DB_ENV


async def main():
    Config("test.toml")
    env = DB_ENV("db") # можно указать имя соединения, если в конфигурации их несколько
    x = await env.sql("select version()", ONE)
    print(x)
    await env.rollback()
    # ниже соединение будет автоматически создано с теми же параметрами
    await env.sql("create table if not exists t1(id serial primary key, v varchar)")
    await env.sql("insert into t1(v) values ('from env 3')")
    await env.commit()

if __name__ == '__main__':
    asyncio.run(main())
```

#### Расширения синтаксиса sql
для сокращения времени разработки и отладки, функция sql, встроенная в окружение, поддерживает расширенный синтаксис sql:
|**Расширение синтаксиса**   |**Преобразуется в SQL-запрос** |**Примечание**|
|----------------------------|-------------------------------|----------------|
| * имя_таблицы              | select * from имя_таблицы     | 
| \\JSON имя_функции(параметры)| select имя_функции(параметры) | вместо select может быть другая команда, в зависимости от дроайвера СУБД|
| \\JSON имя_функции(JSON)     | select имя_функции($1::json)  | selecn и преобразование ::jsonb зависит от СУБД, **kwargs функции sql будут переданы как параметр типа json|
| \\JSON имя_функции(JSONB)    | select имя_функции($1::json)  | как и выше, но параметр приводится к jsonb (если это поддерживает СУБД, иначе к json)|
| \\d имя_таблицы              |                               | возвращает json, содержащий структуру таблицы |
| \\call имя_функции           |                               | вызывает функцию, подставляя *args, **kwargs как параметры |

При вызове запросов из DB_ENV можно использовать параметры, например:
```python
async def test():
    env = DB_ENV()
    x = await env.sql("select $a * $b", ONE, a=2, b=5)
    print(x) # выведет в консоль 10
```

Если в kwargs метода DB_ENV.sql не передан используемый в запросе, то sql будет искать значение.
Например, тот же вариант, что и в запросе выше можно получить с помощью кода:
```python
async def test():
    env = DB_ENV(a=2, b=2)
    env["b"] = 5
    x = await env.sql("select $a * $b", ONE)
    print(x) # также выведет 10
```

## Работа с курсорами таблиц БД

Курсор таблицы может быть получен из DB_ENV посредством функции table. 
В свою очередь, курсор предоставляет следующие функции и свойства:
```python
class Table:
    @classmethod
    async def create(cls, table_name, db:DB, page_size=100, auto_close_conn=True, async_delay=0.0001):
        """
        Создает объект для работы с указанной таблицей. [Не рекомендуется использование Table(...)]
        При работе таблица сортируется по ключу, если ключа нет, то по первому полю
        :param table_name: Имя таблицы
        :param db: опция DB для соединения с БД
        :param page_size: размер окна для чтения таблицы
        :param auto_close_conn: если True, то соединение автоматически закрывается после каждого чтения или записи таблицы
        :param async_delay: время передачи управления другим процессам при использовании таблицы как итератора
        :return:
        """
        
    @property
    def position(self):
        """строка, на которой стоит курсор (счет с 0)"""

    async def seek(self, position):
        """устанавливает курсор на заданную строку"""
        
    async def seek(self, *args, **kwargs):
        """устанавливает курсор на строку, соответствующую фильтру заданному в *args, **kwargs"""

    async def first(self):
        """
        переход на первую запись курсора
        """
    
    async def next(self):
        """
        переход на следующую запись
        если таблица еще не читалась - читает ее и встает на первую запись
        """

    async def prior(self):
        """
        переход на предыдущую запись
        """

    async def last(self):
        """
        переход на последнюю запись
        """

    @property
    def BOF(self):
        """
        :return: True, если курсор на первой записи
        """

    @property
    def EOF(self):
        """
        :return: True, если курсор на последней записи
        """

    async def filter(self, *args, **kwargs):
        """
        Задает фильтр для таблицы
        в args можно перечислять операции сравнения, например t.row.v == 1, где t - объект таблицы, м - имя поля
        операции сравнения в args могут объединяться в tuple и тогда относятся друг к другу по AND, либо в list - тогда OR
        args это tuple, соответственно соединение AND
        например, filter(t.row.a==1,[t.row.b==2,t.row.b==3]) будет преобразовано в: t.a = 1 and (t.b = 2 or t.b = 3)
        list и tuple могут быть вложены в другие list и tuple

        В kwargs все проще: ключ это имя поля, а значение - значение поля. При этом условия связаны по AND
        например, filter(a=1, b=2) будет преобразовано в: t.a = 1 and t.b = 2
        """

    async def save(self, *args, **kwargs):
        """
        При вызове без параметров, сохраняет текущие изменения в таблице
        Если в args[0] передан список записей - сохраняет его
        Если в kwargs переданы значения полей - сохраняет запись
        ---
        В случае если в таблице уже есть запись с таким ключем, как в записи, переданной в save
        происходит обновление записи, иначе вставка.
        """

    async def delete(self, *args, **kwargs):
        """
        При вызове без параметров удаляет строку, на которой стоит курсор
        Если вызван с параметрами - удаляет записи соответствующие фильтру, заданному параметрами
        """

    @property
    def key(self):
        """
        :return:  имя ключа или список полей составного ключа
        """
        
    @key.setter
    def key(self, value):
        """
        позволяет установить имя ключа или список имен полей составного ключа, отличный от 
        полученного из БД при инициализации таблицы
        """
        
    @property
    def field_names(self)->tuple:
        """
        :return:  tuple с именами полей таблицы
        """
        
    @property
    def name(self):
        """
        :return: имя таблицы
        """
    @property
    def row(self) :
        """
        Поля текущей строки таблице, к полям можно обращаться через строковое имя в квадратных скобках, либо по имени через точку
        т.е. t.row.id и t.row["id"] это одно и то же
        """

    async def count(self, *args, **kwargs):
        """
        :param args:  --/- Если заданы, то по ним строится фильтр (аналогично filter). Иначе используется фильтр таблицы
        :param kwargs: /   Если в args передать опцию ALL, то результат будет вычислен по всей таблице (без фильтра)
        :return: Количество записей в таблице
        """

    async def __call__(self, *args, **kwargs):
        """
        При вызове таблицы как функции, возвращает данные таблицы как список строк
        умеет модифицировать результат посредством опций ONE, ROW, DICT, TUPLE, OBJECT и PAGE
        кроме того, в args и kwargs можно передать условия фильтра (аналогично filter), в этом случае будет использован 
        заданный фильтр, а не фильтр по умолчанию.
        Если в args передать опцию ALL, то результат будет вычислен по всей таблице (без фильтра)
        """
```

Свойство row класса Table предоставляет экземпляры полей, которые имеют следующий интерфейс:
```python
class Field:
    @property
    def table(self):
        """ссылка на таблицу, к которой относится поле"""

    @property
    def name(self):
        """имя поля"""

    @property
    def python_type(self):
        """тип python, соответствующий значениям поля"""

    @property
    def not_null(self):
        """признак того, что поле не может принимать значение None"""
        
    @property
    def value(self):
        """возвращает значение поля"""
        
    @value.setter
    def value(self, value):
        """устанавливает значение поля"""
        
    async def min(self):
        """возвращает минимальное значение поля с учетом текущего фильтра таблицы"""
        
    async def max(self):
        """возвращает максимальное значение поля с учетом текущего фильтра таблицы"""
        
    async def avg(self):
        """возвращает среднее значение поля с учетом текущего фильтра таблицы"""
        
    async def sum(self):
        """возвращает сумму значений поля с учетом текущего фильтра таблицы"""
    
    async def count(self):
        """возвращает количество уникальных значений поля с учетом текущего фильтра таблицы"""
```

Пример использования курсора таблицы:
```python
from ab_engine import Config
from ab_engine.env import DB_ENV
from ab_engine.db import OBJECT
from asyncio import run

async def test():
    Config("test.toml")
    env = DB_ENV()
    tbl = await env.table("test", if_not_exists=[ # если таблицы не существует
        # создать таблицу 
        """
        create table test(
            id serial primary key,
            val varchar
        )""",
        # заполнить таблицу тестовыми значениями
        """
        insert into test(val)
        select format('Запись № %s', generate_series)
        from generate_series(1, 100)
        """
    ])
    
    # выбрать записи с id==2 или id==5
    x = await tbl([
         tbl.row.id==2,
         tbl.row.id==5
    ])
    print(x)
    print("------------")
    
    # тоже выбрать записи с id==2 или id==5
    where = (tbl.row.id==2) | (tbl.row.id==5)
    print(where)
    x = await tbl(where)
    print(x)
    print("------------")
    
    # выбрать записи с val=="Запись № 7" и вернуть результат как namedtuple
    x = await tbl(tbl.row.val=="Запись № 7", OBJECT)
    print(x)
    print("------------")

    # обход всех записей курсора
    async for row in tbl:
        print(row.id.value, tbl.position) # вывод значения поля id и номер строки
    print("------------")

    # установить курсор на строку с номером 30
    await tbl.seek(30)
    for x in range(5):
        await tbl.prior() # передвинуть курмор на строку назад
        print(tbl.row) # вывод всех значений всех полей строки

    await tbl.first() # установить курсор на первую запись
    for x in range(5):
        print(tbl.row)
        await tbl.next() # перейти на следующую строку
    print("------------")
    await tbl.seek(3)
    print(tbl.row)
    # поменять значение поля val в строке 3
    tbl.row.val.value = "Новое значение" if tbl.row.val.value=="Запись № 4" else "Запись № 4"
    print(tbl.row)
    await env.commit() # сохранить изменения


if __name__ == '__main__':
    run(test())
```

## Единообразное выполнение функций python и sql

Единообразное выполнение функций python и sql реализовано как часть механизма поддержки удаленного вызова функций, 
реализованного в рамках библиотеки. Этот механизм также поддерживает работу с плагинами.

В библиотеке реализованы:

* регистрация методов python и sql для вызова RPC
* вызов зарегистрированных методов
* вызов методов по стандарту JsonRPC

### Регистрация методов

Методы RPC регистрируются c помощью декоратора:
```python
from ab_engine import register_rpc
from ab_engine.db import ONE

@register_rpc("version")
def SQL():
    # чтобы функция зарегистрировалась как запрос, она не должна быть корутиной, не должна иметь параметров и должна называться SQL
    # если такая функция вернет строку, то эта строка будет использоваться как запрос
    # если функция вернет tuple, то первый элемент - запрос, а остальные - параметры args
    return("select version()", ONE)

@register_rpc
def hello(name="World"):
    return f"Hello {name}!"

@register_rpc
async def ver2(env):
    # функция RPC может быть корутиной
    # если у функции задан параметр с именем **env**, то в него будет передан текущий контекст (наследник DB_ENV)
    x = await env.sql("select version()", ONE)
    return x.split("(",1)[0]
```
Также можно зарегистрировать метод RPC, если вызвать **register_rpc** как простую функцию:
```python
from ab_engine import register_rpc

def hello(name="World"):
    return f"Hello {name}!"

def init():
    register_rpc("version", "select version()") 
    # register_rpc("version", ("select version()", ONE)) - можно было вызвать так и передать опцию
    register_rpc(hello)
```

Как бы ни были зарегистрированы функции RPC, они могут быть вызваны кодом из примера:
```python
from ab_engine import Config, call_rpc
from ab_engine.env import DB_ENV

async def main():
    Config("config.yaml")
    x = await call_rpc("version")
    print(x)
    x = await call_rpc("hello", name="all")
    print(x)
```

Таким образом, функция **register_rpc** регистрирует запрос, плагин или функцию python как метод RPC.
Может быть вызвана как декоратор или обычная функция с параметрами:

def register(name:Optional[str|Callable]=None,defs=None, help=None) -> Callable:

**name** - имя для RPC вызова функции

**defs** в зависимости от способа вызова и типа регистрируемой функции:
* для sql - текст запроса, в том числе с расширениями, поддерживаемыми DB_ENV, либо tuple,
где первый элемент это запрос, а остальные параметры, передаваемые в *args при вызове запроса.
* для функции python - ссылка на функцию. если данная функция не корутина, не имеет параметров и носит имя SQL,
то результат выполнения функции также определяет sql запрос.
* для определения плагина не должна включать пробельных символов и может быть задан в одном из следующих вариантов:
    * путь к модулю, содержащему функцию
    * имя модуля, содержащего функцию (будет найден по путям поиска, заданным в конфиг. defaults.plugin_path
    * путь или имя модуля:имя функции - позволяет вызывать функцию, имя которой отличается от имени при RPC
**help** позволяет переопределить документацию функции

### Вызов методов RPC

RPC это Remote (удаленный) Procedure (процедур) Call (вызов). Данная библиотека не предлагает какого-либо транспорта 
для действительно удаленного вызова процедур и функций, предоставляя только регистрацию и вызов зарегистрированных
функций в коде, который непосредственно использует библиотеку. Какой именно транспорт будет поддерживаться определяется
на уровне этого кода.

Самый простой и гибкий вариант вызова зарегистрированных функций из использующего библиотеку кода это функция **call_rpc**.
```python
async def call_rpc(name_of_rpc_method_for_call, current_rpc_environment_for_call=None, **kwargs):
    """
    Выполняет вызов RPC
    :param name_of_rpc_method_for_call: имя метода
    :param current_rpc_environment_for_call: окружение, в котором будет выполняться метод
    :param kwargs: параметры метода
    :return: результат выполнения метода
    """
```
Т.е. для выполнения зарегистрированного метода достаточно передать имя метода в первом параметре call_rpc.
Во втором параметре можно передать контекст (DB_ENV), который может быть нужен, например, для того чтобы выполнить
несколько методов в одной транзакции.
Соответственно в вызываемую функцию параметры могут быть переданы через kwargs.

### Поддержка JsonRPC

Библиотека предоставляет функцию call_json, которая реализует обработку словарей и списков python, как сообщений JsonRPC.
Т.е. словарь на входе данная функция воспринимает как одиночное сообщение JsonRPC в формате:
```json
{
  "jsonrpc": "Версия протокола JsonRPC. опциональною по умолчанию 2.0",
  "method":  "Обязательный параметр, содержащий имя вызываемого метода",
  "params":  "Необязательная структура (dict|list), содержащая значения параметров.",
  "id":      "Необязательный параметр, содержащий идентификатор сообщдения"
}
```
Eсли params содержит list, то данные при этом всё равно передаются методу через kwargs, а список будет передан в LIST_OF_PARAMS

Если же list передан непосредственно на вход call_json, то каждый элемент такого списка считается сообщением и все эти
сообщения будут обрабатываться параллельно (через gather), каждое в своем контексте, на своем соединении БД.
Ответы каждого сообщения также будут помещены в список, а этот список вернется как результат call_json.

Список сообщений, также можно передать в call_json как tuple. В этом случае сообщения будут обрабатываться в той последовательности,
в которой заданы в tuplе, по возможности, в одном контексте (в одной транзакции)

Также вторым параметром на вход call_json можно передать строку соединения БД или контекст для выполнения команд. 
В случае выполнения списка команд (list) будет создаваться копия соединения для каждой команды.

Третьим параметром можно задать словарь с переменными, которые будут доступны из контекста выполнения команд.

Пример call_json:

```python
from ab_engine import register_rpc, call_json, Config
from ab_engine.db import ONE
import asyncio

@register_rpc("version")
def SQL():
    return("select version()", ONE)

@register_rpc
def hello(name="World"):
    return f"Hello {name}!"

async def main():
    message = [
        {
            "method": "version",
            "id": 1
        },
        {
            "method": "hello",
            "params": {"name": "all"},
            "id": 2
        }
    ]

    Config("config.yaml")
    x = await call_json(message)
    print(x)

if __name__ == '__main__':
    asyncio.run(main())
```

## Поддержка плагинов

Плагины, как и sql-запросы и функции python регистрируются с помощью функции register_rpc.
Создадим plugin.py с примером плагина:
```python
def test(**kwargs):
    if kwargs:
        return {"kwargs": str(kwargs)}
    return {"kwargs":"kwargs is empty"}

async def async_test():
    print("is async!!")
```
и код с примером вызова плагина:
```python
from ab_engine import register_rpc, call_rpc
import asyncio

register_rpc("test", "./plugin.py") # имя регистрируемой функции совпадает с именем в плагине
register_rpc("async", "./plugin.py:async_test") # регистрация функции с другим именем

async def main():
    x = await call_rpc("plugin", a=1, b=2)
    print(x)
    await call_rpc('async')

if __name__ == '__main__':
    asyncio.run(main())
```
Из этого примера можно увидеть как регистрируются функции плагинов.
Также, из примера следует, что функция call_rpc умеет вызывать как асинхронные, так и обычные функции.

Функции плагинов могут получать экземпляр текущего окружения (DB_ENV).
Для того чтобы функция получала экземпляр окружения, в ней должен быть явно объявлен параметр env.
Добавим в файл plugin.py следующую функцию:
```python
async def with_env(env, **kwargs):
    x = env.data.copy()
    x.update(kwargs)
    return x
```
Также нам понадобится файл test.toml следующего содержания:
```toml
[defaults]
plugin_path = "./"
```

И основной код примера:
```python
from ab_engine import register_rpc, call_rpc, Config
from ab_engine.env import DB_ENV
import asyncio

async def main():
    Config("test.toml")
    register_rpc("plugin", "plugin.py:with_env") # не указан путь
    env = DB_ENV(c=3, d=4)
    x = await call_rpc("plugin", env, a=1, b=2)
    print(x)

if __name__ == '__main__':
    asyncio.run(main())
```
В данном примере в функцию плагина будет передан экземпляр окружения, из которого она возьмет параметры и объединит 
их с параметрами, переданными в функцию.

В этом примере следует отметить, что путь к коду плагина не указан в параметре функции register_rpc.
Путь поиска плагинов в этом примере задан в файле test.toml в параметре plugin_path.
В параметре plugin_path можно указать несколько путей поиска, разделенных ; (точкой с запятой), поиск 
файла в этом случае будет идти в том порядке, в котором перечислены пути.  
