Metadata-Version: 2.4
Name: mockable
Version: 0.1.2
Summary: mockable for ses
Author-email: Arimaqqe <beebo120103@gmail.com>
License: MIT
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests<3,>=2.28
Dynamic: license-file

# mockable

Библиотека подставляет заранее подготовленные данные, чтобы вы могли проверить
работу приложения без обращения к серверу. Например, можно проверить ситуацию
«свободных мест нет», даже когда настоящий сервер возвращает свободные места.

Для интеграции в `start.py` нужны **два декоратора**:

- `@mockable_class(config_factory=build_mock_config)` над классом.
- `@http_mockable` над общим методом `_http_request`.

При `self.cfg.is_mock=True` моки включаются автоматически на время вызова метода.
Внешние файлы вызывают методы как обычно. Не нужно добавлять `with`, изменять
`handler` или вручную вызывать `set_mock_config`.

## 1. Установите библиотеку

Для опубликованной версии установка выглядит так:

```sh
pip install mockable
```

Текущее расширение находится в исходниках этого проекта и пока не опубликовано
отдельным релизом. До публикации устанавливайте **эту локальную версию** из папки
с `pyproject.toml`, иначе из PyPI может установиться версия без нового API:

```sh
python -m pip install -e .
```

Нужен Python 3.10 или новее. Зависимость `requests` устанавливается вместе
с библиотекой. `-e` позволяет использовать изменения исходников без переустановки.

## 2. Добавьте настройки и функцию конфигурации в start.py

Полный учебный файл находится в [examples/start_like.py](examples/start_like.py).
В нём всё в одном месте: настройки, моки, класс и обычный `handler`.
Ниже показан вариант с настоящей отправкой requests при выключенных моках.
Адрес `example.test` вымышленный; замените его адресом своего API.

Добавьте в начало своего `start.py`:

```python
from dataclasses import dataclass
import requests

from mockable import (
    MockConfig, MethodMockConfig, MockFixture, TestCaseType,
    HttpKey, HttpMockConfig, HttpFixture, mockable_class, http_mockable,
)


# Шаг 1. Если MedicineConfig уже существует, используйте его.
@dataclass
class MedicineConfig:
    is_mock: bool = False
    base_url: str = "https://example.test"


# Шаг 2. Функция создаёт конфиг. Сами словари с ответами находятся внутри неё.
# api — объект MedicineApi, для которого библиотека включает моки.
def build_mock_config(api):
    queue = HttpKey(
        url=api.cfg.base_url + "/queues/get/info",
        name="queue_info",
    )
    return MockConfig(
        # Здесь выбирается кейс для подмены функции целиком.
        active_cases={"get_patient": TestCaseType.POSITIVE},
        methods={
            "get_patient": MethodMockConfig(
                positive=MockFixture(result={"name": "Тестовый пациент"}),
                negative=MockFixture(result={"name": "Пациент не найден"}),
            ),
        },
        # Здесь выбирается кейс для ответа на HTTP-запрос.
        http_active_cases={queue: TestCaseType.POSITIVE},
        http_requests={
            queue: HttpMockConfig(
                positive=HttpFixture(result={"items": [{"id": 101}]}),
                negative=HttpFixture(result={"items": []}),
            ),
        },
        http_unmatched="raise",
    )
```

`build_mock_config` должна быть объявлена выше класса. Передайте декоратору
саму функцию, без скобок: `config_factory=build_mock_config`.
Библиотека вызовет её сама при первом вызове метода объекта с `is_mock=True`.

При `is_mock=False` эта функция не вызывается: словари ответов и фикстуры
не создаются, файлы моков не читаются. Импорты и код самой функции, как любой
Python-код, остаются в памяти.

## 3. Повесьте два декоратора

Продолжение того же `start.py`:

```python
# Шаг 3. Один декоратор охватывает обычные методы всего класса.
@mockable_class(config_factory=build_mock_config)
class MedicineApi:
    def __init__(self, *, config=None):
        self.cfg = config or MedicineConfig()

    # Здесь отдельный @mockable не нужен.
    def get_patient(self, *, session):
        return {"name": "Пациент из обычного кода"}

    # Шаг 4. Второй декоратор ставится только над общей отправкой запросов.
    @http_mockable
    def _http_request(self, *, session, method, url, name):
        response = requests.request(method=method, url=url, timeout=5)
        response.raise_for_status()
        return response.json()

    # Этот метод продолжит работать: мок подменяет только HTTP-ответ.
    def get_queue(self, *, session):
        data = self._http_request(
            session=session,
            method="GET",
            url=self.cfg.base_url + "/queues/get/info",
            name="queue_info",
        )
        return len(data["items"])
```

В вашем существующем `_http_request` сохраните тело метода и добавьте только
декоратор. Если он использует `apiRequest`, а та вызывает requests, библиотека
перехватит запрос ниже неё. Менять `app.tools` или добавлять туда импорт
mockable не нужно. Метод должен принимать `name`; данные `session` или
`sessiondata` также будут доступны HTTP-фикстуре.

В бизнес-методах исходного `start.py` есть ранние возвраты вида
`if self.cfg.is_mock: return ...`. При переносе соответствующих ответов
в конфиг уберите эти возвраты. Иначе выполнение закончится до HTTP-запроса.

## 4. Вызывайте методы из любого файла

```python
from start import MedicineApi, MedicineConfig

api = MedicineApi(config=MedicineConfig(is_mock=True))

def main():
    patient = api.get_patient(session={})
    places = api.get_queue(session={})
    print(patient)  # {'name': 'Тестовый пациент'}
    print(places)   # 1

if __name__ == "__main__":
    main()
```

В `get_patient` библиотека сразу вернёт значение из `methods`, не выполняя
тело метода. В `get_queue` выполнится весь код: requests получит заданный JSON,
а `len(data["items"])` вычислит число мест. Повторные обращения получают
тот же ответ выбранного кейса.

Для обычного запуска передайте `MedicineConfig(is_mock=False)`.
Если нужно сохранить именно `api = MedicineApi()`, выбирайте `is_mock`
в настройках, которые использует конструктор по умолчанию.

## Что и где менять

| Задача | Место изменения |
| --- | --- |
| Включить или выключить моки | `MedicineConfig.is_mock` |
| Заменить результат метода | `methods["имя_метода"]` |
| Задать ответ сервера | `http_requests[HttpKey(url, name)]` |
| Выбрать кейс метода | `active_cases["имя_метода"]` |
| Выбрать кейс запроса | `http_active_cases[HttpKey(url, name)]` |

Если метод присутствует в `methods`, он заменяется целиком. Его внутренние
запросы не выполняются. Для проверки его бизнес-логики уберите запись метода
из `methods` и настройте нужные HTTP-ответы в `http_requests`.

## URL, имя и несколько запросов

`HttpKey(url="https://example.test/info", name="queue_info")` описывает пару:
полный адрес и имя запроса. Пишите точно тот `name`, который передаётся
в `_http_request`. Имена чувствительны к регистру.

Для нескольких запросов добавьте по записи на каждую пару URL и имени.
Запросы выбирают свои ответы независимо; порядок записей в словаре не важен.
Один URL с разными именами может иметь разные моки.

Путь `/info` отличается от `/info/`. Параметры после `?` и fragment после `#`
не входят в ключ. Например, `?id=1` и `?id=2` при одинаковом имени используют
одно правило; если ответ зависит от параметра, используйте `side_effect`.

Можно ограничить метод: `HttpMockConfig(method="GET", positive=...)`.
Чтобы отдельно настроить GET и POST одного адреса, задайте им разные имена.
Пустой `name=""` соответствует запросам без имени, а не любому имени.

Поддерживаются стандартные `requests.request`, `requests.get/post/...`,
`requests.Session().request` и отправка подготовленного запроса через
`Session.send`. Для Session внутри `_http_request` действует тот же декоратор.

## Кейсы

| Кейс | Что проверять |
| --- | --- |
| `TestCaseType.POSITIVE` | Штатное успешное выполнение |
| `TestCaseType.NEGATIVE` | Ожидаемый отрицательный результат, например нет мест |
| `TestCaseType.SPECIAL` | Особый или граничный случай |
| `TestCaseType.ERROR` | Ошибка сервера или соединения |

В конфиге из примера поменяйте `http_active_cases={queue: TestCaseType.NEGATIVE}`,
чтобы `get_queue` вернул 0. Для другого ответа метода поменяйте
`active_cases={"get_patient": TestCaseType.NEGATIVE}`.

`positive` обязателен; остальные поля `negative`, `special`, `error` необязательны.
Без явного выбора используется POSITIVE. Если выбранный кейс не описан,
используется positive, как в прежнем API. Название ERROR само по себе
не вызывает исключение: результат определяется его фикстурой.

Конфиг создаётся один раз на объект и хранится, пока жив объект. После изменения
фабрики или настроек кейсов создайте новый объект для новой проверки.
Если переключить существующий объект на `is_mock=False`, следующий вызов метода
освободит его сохранённый конфиг. При последующем включении он создастся заново.
Не изменяйте настройки объекта посреди выполняющегося метода.

## Фикстуры: данные, файлы и ошибки

`MockFixture` для функций и `HttpFixture` для HTTP поддерживают:

| Поле | Назначение |
| --- | --- |
| `result` | Готовое значение |
| `file_path` | Путь к JSON-файлу, читаемому при обращении |
| `side_effect` | Функция, вычисляющая ответ |
| `exception` | Исключение, которое нужно выбросить |
| `description` | Пояснение для человека |

Обычно задавайте один источник. Если источников несколько, приоритет:
exception, side_effect, file_path, result.

Большой ответ можно хранить в файле:

```python
from mockable import HttpFixture

answer = HttpFixture(file_path="mocks/queue.json")
```

Файл должен содержать JSON, например `{"items": []}`. Относительный путь
отсчитывается от папки запуска программы. Абсолютный путь не зависит от неё.

У HTTP-фикстуры есть `status_code` (по умолчанию 200) и `headers`:

```python
from requests.exceptions import Timeout
from mockable import HttpFixture

server_error = HttpFixture(status_code=503, result={"message": "Unavailable"})
connection_error = HttpFixture(exception=Timeout("Сервер не ответил"))
answer = HttpFixture(result={"items": []}, headers={"X-Test": "yes"})
```

HTTP-статус 503 сам по себе не выбрасывает исключение; его может выбросить
`response.raise_for_status()`. Исключение Timeout из фикстуры выбрасывается
при запросе. Ваша `apiRequest` может перехватить его своим `except` и вернуть
`(500, exception)` — дальше работает обычная логика приложения.

Словари, списки, числа, bool и None сериализуются в JSON. Строки кодируются
в UTF-8, bytes передаются как есть. None означает JSON null, `b""` — пустое тело.
Content-Type для JSON добавляется, если его не задали вручную.

## Ответ зависит от параметров

В `HttpFixture.side_effect` передаётся один объект с данными запроса:

```python
from mockable import HttpFixture

def build_reply(request):
    return {"ids": request.query.get("id", []), "items": []}

answer = HttpFixture(side_effect=build_reply)
```

Доступны `method`, `url`, `name`, `headers`, `body`, `query`, `sessiondata`.
`query` хранит списки значений URL-параметров. `body` — подготовленное requests
тело: байты, строка или None. Функция возвращает тело ответа; статус и заголовки
берутся из HttpFixture.

В обычной `MockFixture.side_effect` передаются аргументы заменяемой функции,
включая `self` для метода. Этот старый контракт сохранён.

## Даты в JSON-файле

В библиотеке есть готовая функция `json_replace_datetime_expression`.
Она возвращает HTTP side_effect: читает JSON-файл и подставляет даты при каждом
запросе. Копировать реализацию в свой скрипт не нужно.

```python
from mockable import HttpFixture
from mockable.side_effects import json_replace_datetime_expression

positive = HttpFixture(
    side_effect=json_replace_datetime_expression(
        f"{MOCKS_DIR}/patient_search/positive.json",
        expression="now-1d",
    ),
)
```

Обычный маркер `$mockable_var` заменится датой из параметра expression
(по умолчанию `now`). Для разных дат в одном файле задайте выражения в маркерах:

```json
{
  "created_at": "${mockable_var:now-1d}",
  "updated_at": "${mockable_var:now-1m}",
  "month_start": "${mockable_var:now/M}",
  "default_date": "$mockable_var"
}
```

Поддерживается ограниченный синтаксис в стиле Grafana:

| Выражение | Значение |
| --- | --- |
| `now` | Сейчас |
| `now-1m` | Минуту назад |
| `now+2h` | Через два часа |
| `now-1d` | День назад |
| `now-1w` | Неделю назад |
| `now-1M` | Календарный месяц назад |
| `now-1Q` | Три календарных месяца назад |
| `now-1y` | Календарный год назад |
| `now-1d+2h` | День назад, затем плюс два часа |
| `now/d` | Начало сегодняшнего дня |
| `now-1M/M` | Начало прошлого месяца |

Единицы: s, m, h, d, w, M, Q, y. Регистр важен: m — минуты, M — месяцы.
Сдвиги выполняются слева направо. Не существующий в целевом месяце день
заменяется последним днём этого месяца. Суффикс `/единица` разрешён только
в конце и округляет вниз до начала периода; неделя начинается с понедельника.
Финансовые периоды, абсолютные даты и округление до конца периода не поддерживаются.

Без дополнительных параметров время берётся из локального datetime.now()
сервера один раз на ответ. Результат — ISO 8601 с точностью до секунды,
без часового смещения. Это поведение прежних вызовов сохранено.
Файл читается при каждом вызове и не перезаписывается. Меняются только строковые
значения, включая вложенные списки и объекты; ключи не меняются. Строка
`"now-1d"` без маркера остаётся обычным текстом. Ошибка в выражении вызывает ValueError.

### Формат даты и часовой пояс

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

```json
{
  "date": "${mockable_var:now+1d:YYYY-MM-DD}",
  "timestamp": "${mockable_var:now:YYYY-MM-DDTHH:mm:ssZ}"
}
```

Для времени со смещением `+10:00` задайте часовой пояс в Python:

```python
from mockable import HttpFixture
from mockable.side_effects import json_replace_datetime_expression

positive = HttpFixture(
    side_effect=json_replace_datetime_expression(
        "response.json",
        timezone="+10:00",
    ),
)
```

Если в этом часовом поясе сейчас 23 октября 2026 года, 10:15:00, результат:

```json
{
  "date": "2026-10-24",
  "timestamp": "2026-10-23T10:15:00+10:00"
}
```

Параметр `date_format="YYYY-MM-DD"` задаёт общий формат для `$mockable_var`
и маркеров без своего формата. Формат внутри маркера имеет приоритет.
Двоеточия внутри `HH:mm:ss` поддерживаются. При отсутствии формата используется
ISO 8601; с `timezone="+10:00"` он уже содержит смещение, без необходимости писать Z.

| Токен | Значение |
| --- | --- |
| `YYYY` | Год, четыре цифры |
| `MM` | Месяц |
| `DD` | День месяца |
| `HH` | Час, 00–23 |
| `mm` | Минуты |
| `ss` | Секунды |
| `SSS` | Миллисекунды |
| `Z` | Смещение вида +10:00 |
| `ZZ` | Смещение вида +1000 |

Это ограниченный набор токенов, не полный язык форматов Grafana и не strftime.
Буква T может использоваться как разделитель. Произвольный текст заключайте
в квадратные скобки: `[T]`, `[UTC]`. `[Z]` выводит буквальную Z, а не смещение;
используйте её только с UTC, например `YYYY-MM-DD[T]HH:mm:ss[Z]`.

`timezone` принимает `"UTC"`, `"Z"`, смещение `"+10:00"` / `"-03:30"`
или объект Python tzinfo. Строковые названия регионов не поддерживаются;
при необходимости передайте объект ZoneInfo с доступной базой часовых поясов.
Смещение задаёт часовой пояс вычисления now и округлений, а не просто приписку
к локальному времени сервера. Фиксированное смещение не учитывает сезонный перевод часов.
Токены Z/ZZ требуют явного timezone; неизвестный формат или часовой пояс вызывает ValueError.

## Если мок не сработал

Проверьте `self.cfg.is_mock`, оба декоратора и `config_factory`. Проверьте,
что фабрика возвращает MockConfig, а URL и имя совпадают с реальным запросом.
Убедитесь, что бизнес-метод не завершился раньше из-за собственного
`if self.cfg.is_mock: return ...` и не заменён целиком через `methods`.

По умолчанию `http_unmatched="raise"`: незаданный HTTP-запрос вызывает
`MockRequestNotMatched` без отправки в сеть. Для намеренно смешанного режима
можно выбрать `"real"`, разрешив незаданным запросам настоящий сервер.

Если приложение проглотило ошибку ненайденного мока, библиотека сообщит о ней
при завершении внешнего вызова метода. Исключение приложения, уже выходящее
из метода, не заменяется другим исключением.

## Совместимость и границы


Существующие `@mockable`, `@mockable_class` без параметров и `set_mock_config`
сохраняют старое поведение. Для отдельной функции:

```python
from mockable import mockable, set_mock_config, MockConfig, MethodMockConfig, MockFixture

@mockable
def greeting():
    return "Обычный ответ"

set_mock_config(MockConfig(methods={
    "greeting": MethodMockConfig(positive=MockFixture(result="Тестовый ответ")),
}))
try:
    print(greeting())
finally:
    set_mock_config(None)
```

Автоматический класс с `config_factory` использует конфиг своего экземпляра,
а не глобальный `set_mock_config`. Вложенные вызовы того же объекта используют
его активную конфигурацию; разные объекты изолированы. Обычные унаследованные
методы тоже покрываются. Конструктор, специальные методы и property автоматически
не подменяются. Объект должен позволять хранение служебного атрибута конфигурации.

Для staticmethod/classmethod нет экземпляра с `cfg`: прямой вызов этих методов
не включает автоматические моки. Если они вызываются внутри обычного метода
объекта, они используют уже активный конфиг. Старый глобальный режим для них сохранён.

Перехват рассчитан на синхронный requests. Другие HTTP-клиенты, собственные
переопределения `Session.send` и прямые вызовы адаптеров не покрываются.
Моки не воспроизводят автоматически цепочки редиректов. Запросы логгера через
requests также подпадают под активные правила.

В рабочем потоке вызывайте обычный декорированный метод объекта: он сам активирует
конфиг. Прямой requests в новом потоке не наследует контекст вызывающего потока.
Фабрика выполняется один раз; её результат и `side_effect` не должны содержать
небезопасное совместно изменяемое состояние при параллельных вызовах.

Для отдельных низкоуровневых тестов оставлены `http_mocks(config)` и
`http_request_name(name)`, но при интеграции класса они не нужны.
Предыдущее написание `mockable_http` работает как синоним `http_mockable`.

## Примеры и тесты из исходников

Первый уровень — короткие примеры:

- [Подмена функции](examples/simple/function_result.py).
- [Автоматический HTTP-мок](examples/simple/http_response.py).

Второй уровень — [один файл по образцу start.py](examples/start_like.py).
В нём при выключенных моках есть учебная заглушка вместо настоящего сервера.
При переносе используйте свою реализацию `_http_request`.

После установки пакета из корня проекта:

```sh
python -m examples.simple.function_result
python -m examples.simple.http_response
python -m examples.start_like
python -m unittest discover -s tests
```

Можно запускать без установки локального пакета, используя подготовленное окружение:

```powershell
$env:PYTHONPATH = "$PWD/src"
.\.venv\Scripts\python -m examples.start_like
.\.venv\Scripts\python -m unittest discover -s tests
```
