Skip to main content

geas

Тестовые контракты, JSON Schema, d42-схемы и operation-aware моки, сгенерированные из checked-in OpenAPI.

Geas — обязательное условие или запрет из ирландской и шотландской традиции: соблюдение даёт силу, нарушение неизбежно имеет последствия. Здесь таким условием становится OpenAPI-контракт: библиотека превращает его в исполняемые схемы и сразу показывает место, где fixture, mock или настоящий запрос перестал ему соответствовать.

  • Версия: 0.2.0
  • Python: 3.10+
  • Ядро зависит только от jsonschema[format-nongpl] и PyYAML. d42 и JJ — опциональные extras.

1. Зачем это нужно: contract drift

Тест поднимает мок и кладёт в него руками написанное тело ответа. Бэкенд меняет схему: переименовывает поле, делает его nullable, убирает из required, добавляет вариант в enum. Мок продолжает отдавать старое тело, тест продолжает быть зелёным — и остаётся зелёным ровно до того момента, когда фича доезжает до продакшена. Это и есть contract drift: тест проверяет не сервис, а собственную устаревшую фикстуру.

Библиотека закрывает это одним воспроизводимым пайплайном:

checked-in OpenAPI
  → нормализованный контракт
  → generated JSON Schema и d42
  → generator overlays
  → generated operation handles
  → operation-aware JJ-моки
  → проверка drift в CI

Что даёт каждое звено:

Звено Что происходит
checked-in OpenAPI спецификация лежит в репозитории; сеть не используется никогда
нормализованный контракт Swagger 2.0 и OpenAPI 3.0.x сводятся к одному IR; неподдержанная конструкция — ошибка с точным адресом, а не «любое значение»
generated JSON Schema точная семантика oneOf, discriminator, format и границ; работает без extras
generated d42 параллельное представление того же контракта для генерации фикстур
generator overlays подмена генератора отдельного листа (осмысленные названия вместо 9-hK_2 0zQ) без правки generated-файлов
generated operation handles статический typed namespace: operations.ws2.add_ticket
operation-aware JJ-моки тело мока валидируется до регистрации, каждый перехваченный запрос — после выхода из блока
проверка drift в CI geas check роняет сборку, если закоммиченные артефакты разошлись со спецификацией

Главный принцип — fail closed. Ни одна неподдержанная конструкция не превращается молча в «принимает что угодно». Единственное послабление — явный, срочный и закреплённый waiver.


2. Быстрый старт

Установка

pip install geas            # ядро: JSON Schema + CLI + drift-check
pip install 'geas[d42]'     # + генерация d42-схем и фикстур
pip install 'geas[jj]'      # + operation-aware моки
pip install 'geas[all]'     # всё сразу

init — каркас manifest и waivers

geas -m manifest.yaml init \
    --source api/openapi.yaml \
    --directory demo/generated \
    --package demo.generated
создан manifest.yaml
создан waivers.yaml

Дальше:
  geas -m manifest.yaml list
  geas -m manifest.yaml add <ключ> --source main --operation-id <id>
  geas -m manifest.yaml update
  geas -m manifest.yaml check   # это и ставится в CI

Получившийся manifest.yaml:

version: 1
output:
  directory: demo/generated
  package: demo.generated
waivers: waivers.yaml
policies:
  waiver_max_days: 90
  unknown_formats: reject
sources:
  main:
    path: api/openapi.yaml
    selection: explicit
operations: {}

list — что вообще есть в источнике

geas -m manifest.yaml list
источник main: api/openapi.yaml [openapi30]
  режим выбора: explicit, операций: 3
    GET     /api/v1/workspaces/{workspaceId}/documents
      operationId=listDocuments  ключ=-
      responses: 200:application/json, 400:application/json
  * POST    /api/v1/workspaces/{workspaceId}/documents
      operationId=createDocument  ключ=api.createDocument
      request: application/json
      responses: 200:application/json, 400:application/json

* — операция выбрана manifest и попадает в generated-артефакты

add — положить операцию в allowlist

geas -m manifest.yaml add ws2.addTicket \
    --source main --operation-id addTicket
операция ws2.addTicket добавлена в /path/to/manifest.yaml
Теперь запустите 'geas update', чтобы обновить артефакты

add транзакционен: операция целиком нормализуется и рендерится во временный каталог до того, как manifest будет тронут. Если конструкция не поддержана, manifest остаётся байт-в-байт прежним, а в stderr печатается ошибка с готовым рецептом waiver'а. Команда также фиксирует найденные operation_id, method, полный path, единственные JSON request content type и успешный response-вариант, а Python path выводит из стабильного ключа. Если request или успешный response неоднозначны, нужно передать соответствующий селектор явно.

update — сгенерировать артефакты

geas -m manifest.yaml update
обновлено файлов: 8 в /path/to/demo/generated

check — проверить, что закоммиченное совпадает со спецификацией

geas -m manifest.yaml check
артефакты актуальны: 8 файл(ов)

Расхождение печатается в stderr и даёт код возврата 1:

generated-артефакты разошлись со спецификацией:
  отличается:  contracts/api__delete_document.json

Запустите 'geas update' и закоммитьте результат

show — какие поля разрешает контракт

geas -m manifest.yaml show ws2.addTicket --direction response --status 200
ws2.addTicket
Response 200 application/json

$ref #/$defs/ResponseDto; object; дополнительные свойства разрешены
├── description — optional; string
├── entityId — optional; integer; format int64
├── payload — optional; $ref #/$defs/ObjectNode; object; дополнительные свойства разрешены
└── rc — required; string; enum["OK", "ERROR"]

JSON Schema:
  /abs/path/schemas/generated/contracts/ws2__add_ticket.json#/responses/0/schema

d42:
  schemas.generated._d42.ws2__add_ticket_response:GeneratedResponseDtoSchema

d42 source:
  /abs/path/schemas/generated/_d42/ws2__add_ticket_response.py

Команда читает только уже сгенерированные артефакты: она ничего не перегенерирует, не открывает upstream OpenAPI, не ходит в сеть и не требует extra [d42].

Полный справочник по командам и флагам — docs/cli.md.


3. Архитектура

geas/                  ядро: manifest, IR, нормализация, JSON Schema, артефакты, CLI
geas/dialects/         адаптеры диалектов: swagger2, openapi30
geas/integrations/d42/ опционально: IR → d42 2.x, рендер, overlays, фикстуры
geas/integrations/jj/  опционально: OperationHandle.mock() → JJ

Ядро независимо. Оно импортирует только jsonschema и PyYAML. Ни один модуль ядра не импортирует d42 или jj на уровне модуля. Отложенный импорт есть ровно в трёх точках: OperationHandle.mock(), OperationHandle.d42_schema() и рендер d42-модулей внутри render_artifacts. Если extra не установлен, поднимается MissingExtraError с точной командой установки.

Гарантия. Ядро и generated-реестр импортируются на голом окружении без d42 и без JJ. Это проверяется тестами, которые импортируют пакет в подпроцессе с заблокированными d42 и jj в sys.meta_path.

Адаптеры диалектов. Диалект отвечает ровно за одно: привести свой документ к диалектно-нейтральной RawOperation. Ниже по стеку — нормализация, JSON Schema, d42, runtime, CLI — про версию спецификации уже не знают. Различия (definitions против components.schemas, in: body против requestBody, collectionFormat против style/explode, x-nullable против nullable, consumes/produces против content) исчезают на границе dialects/.

Подробное обоснование решений — ADR 0001.


4. Режимы выбора операций: explicit и all

Режим задаётся у источника — sources.<name>.selection.

explicit (по умолчанию)

Генерируются только операции, перечисленные в operations. Запись одновременно работает как allowlist и как закрепление привязки: operation_id, method, path, request.content_type и выбранные responses сверяются со спецификацией на каждом прогоне. Любое расхождение — ManifestBindingError, генерация падает.

sources:
  main: {path: api/openapi.yaml, selection: explicit, base_path: /api/v1}
operations:
  ws2.addTicket:
    source: main
    operation_id: addTicket
    method: POST
    path: /api/v1/queues/{queueId}/tickets
    request: {content_type: application/json}
    responses:
      - {status: 200, content_type: application/json}
    python_path: [ws2, add_ticket]

Операция связывается со спецификацией либо по operation_id, либо по паре method + path; без того и без другого запись отклоняется. Если один operationId встречается в источнике несколько раз, требуется уточнить method и path.

Ключ операции задаётся вручную и является стабильным именем: generated Python path строится из ключа, а не из operationId. Переименовали operationId на бэкенде — падает binding, но публичное Python-имя само не меняется.

Если в manifest уже есть операции, но какой-то explicit-источник не покрыт ни одной из них, это ошибка manifest (почти наверняка опечатка в имени источника). Пустой explicit-источник допустим только пока operations пуст — то есть сразу после init.

all

Генерируются все операции источника. Ключ строится автоматически как <имя источника>.<operationId>, поэтому:

  • операция без operationId — ошибка (ключ невозможно построить детерминированно);
  • дублирующийся operationId внутри источника — ошибка.

Запись в operations для такого источника необязательна и нужна только чтобы уточнить python_path, сузить набор вариантов ответа, задать non_waivable или выключить d42.

sources:
  main: {path: api/openapi.yaml, selection: all}
operations: {}
источник main: api/openapi.yaml [openapi30]
  режим выбора: all, операций: 2
  * GET     /trees
      operationId=getTree  ключ=main.getTree

Что выбирать. explicit — для большой чужой спецификации, где нужны три операции из двухсот и важно, чтобы изменение привязки ломало сборку. all — для маленькой спецификации, которую ведёт та же команда.


5. CLI

geas [-h] [--version] [-m MANIFEST] {init,list,inspect,add,update,check,diff,show}
Команда Что делает
init создать минимальный manifest.yaml и waivers.yaml, ничего не затирая
list (алиас inspect) показать источники, операции, варианты и причины неподдержки
add транзакционно добавить операцию в explicit-allowlist
update детерминированно перегенерировать артефакты
check проверить рабочее дерево на drift, ничего не меняя
diff семантический diff контрактов относительно закоммиченных артефактов
show показать форму уже сгенерированного контракта: поля, обязательность, ограничения и адреса артефактов

list отвечает на вопрос «что вообще есть в спецификации», show — «что разрешает уже закреплённый контракт»: первая читает OpenAPI, вторая — только generated-артефакты и ничего не перегенерирует.

Коды возврата:

Код Значение
0 успех, расхождений нет
1 ошибка контракта: drift, binding, waiver, неподдержанная конструкция
2 ошибка использования CLI

Полный справочник по флагам — docs/cli.md.


6. Generated-артефакты

update пишет в output.directory следующий набор:

Файл Зачем он
__init__.py реэкспорт operations, Operations и REGISTRY
operations.py статический typed namespace операций; атрибуты объявлены как property, поэтому тип виден IDE и mypy, а документ контракта читается лениво
_registry.py индекс «ключ операции → слаг файла» и сам OperationRegistry
contracts/<slug>.json нормализованный контракт операции; внутри лежат JSON Schema тела запроса, тел ответов и всех параметров
_d42/<slug>_request.py, _d42/<slug>_response.py generated d42-схемы направления; создаются, только если d42 включён и в направлении есть тело
_d42/__init__.py пакет d42-модулей; появляется только вместе с ними
_generated.json описание набора: artifact_format, generator, output, operations (slug + семантический fingerprint), общий fingerprint, список owned-файлов и digests

Свойства набора:

  • детерминированность — стабильная сортировка, UTF-8, \n, ни timestamp, ни абсолютных путей, ни случайных значений. Повторный update без изменения входов даёт байт-в-байт тот же результат;
  • атомарность записи — сначала рендерится весь набор (все ошибки случаются здесь), потом каждый файл пишется через временный файл рядом и os.replace;
  • owned-файлы — удаляются только те файлы, которые генератор сам записал в прошлый раз (список owned в _generated.json), с проверкой на выход за каталог и на symlink.

Файлы помечены шапкой «сгенерировано автоматически»; править их руками бессмысленно — check уронит CI на расхождении.

Как этим пользоваться

from demo.generated import operations

op = operations.ws2.add_ticket
op.key  # 'ws2.addTicket'
op.method  # 'POST'
op.path  # '/api/v1/queues/{queueId}/tickets'
op.request.path  # (ParameterView(name='queueId', ...),)
op.request.query  # параметры query
op.request.body()  # RequestBodyView: content_type, required, json_schema, d42_export
op.responses  # (ResponseView(status=200, content_type='application/json', ...),)
op.response(status=200)  # выбор варианта; при единственном варианте аргументы можно опустить
op.unsupported  # варианты, не представимые контрактом, с причинами
op.describe_response(status=200)  # человекочитаемая форма контракта строкой

Валидация вручную, без мока:

op.validate_response(body, status=200)
op.validate_request_body(payload)

Generated d42-схема варианта — через d42_schema(); имя переменной берётся из самого контракта (d42_export), угадывать его не нужно:

from geas import Direction

variant = op.response(status=200)
GeneratedTicketSchema = op.d42_schema(Direction.RESPONSE, export=variant.d42_export)

export обязателен: вызов op.d42_schema(Direction.RESPONSE) без него всегда поднимает OperationLookupError, несмотря на то что у параметра есть значение по умолчанию.

Строковый доступ operations.by_key("ws2.addTicket") и operations.keys() оставлены как escape hatch для инструментов; в тестах используйте generated namespace.

Что разрешает контракт и где он лежит

Ручной alias над generated-схемой сам по себе не показывает форму контракта: в файле проекта видно только имя операции. Чтобы не искать generated-файл глазами, выбранное тело запроса и выбранный вариант ответа знают свои координаты:

response = operations.ws2.add_ticket.response(status=200)

response.contract_file  # PosixPath('/abs/.../generated/contracts/ws2__add_ticket.json')
response.json_pointer  # '/responses/0/schema'  — RFC 6901
response.contract_path  # '/abs/.../ws2__add_ticket.json#/responses/0/schema'
response.d42_module  # 'schemas.generated._d42.ws2__add_ticket_response'
response.d42_export  # 'GeneratedResponseDtoSchema'
response.d42_reference  # 'schemas.generated._d42.ws2__add_ticket_response:GeneratedResponseDtoSchema'
response.d42_source_path  # PosixPath('/abs/.../generated/_d42/ws2__add_ticket_response.py')

d42_source_path вычисляется по раскладке артефактов, без импорта d42-модуля: описание контракта работает и там, где extra [d42] не установлен. Если у варианта своей generated d42-схемы нет (d42 выключен, контракт рекурсивен, вариант без тела), все d42-координаты равны None — путь к чужой схеме не подставляется.

Человекочитаемая форма — describe(); метод возвращает строку и ничего не печатает и не перегенерирует:

print(response.describe())
print(operations.ws2.add_ticket.describe_response(status=200))  # то же самое
print(operations.ws2.add_ticket.describe_request_body())  # для запроса
print(operations.ws2.add_ticket.describe_response(status=200, max_depth=2))
ws2.addTicket
Response 200 application/json

$ref #/$defs/ResponseDto; object; дополнительные свойства разрешены
├── description — optional; string
├── entityId — optional; integer; format int64
├── payload — optional; $ref #/$defs/ObjectNode; object; дополнительные свойства разрешены
└── rc — required; string; enum["OK", "ERROR"]

JSON Schema:
  /abs/path/schemas/generated/contracts/ws2__add_ticket.json#/responses/0/schema

d42:
  schemas.generated._d42.ws2__add_ticket_response:GeneratedResponseDtoSchema

d42 source:
  /abs/path/schemas/generated/_d42/ws2__add_ticket_response.py

Формат — компактная навигация, а не второй валидатор: описание не ослабляет контракт (неизвестное ключевое слово названо, а не превращено в «любое значение»), не разрешает внешние $ref, обрывает рекурсию маркером и ограничивает глубину max_depth. Точная семантика всегда доступна по напечатанному пути. Разбор строк описания в тестах — плохая идея: для программного доступа есть json_schema и geas show --json.

То же самое из терминала — geas show, см. docs/cli.md.


7. Канонический API моков

async with operations.ws2.add_ticket.mock(
    response=response_body,
    path_params={"queueId": queue_id},
    wait_for_requests=1,
) as mock:
    await page.submit()

assert len(mock.history) == 1  # cardinality-ассерт пишет тест

Аргументы mock():

Аргумент Смысл
response тело ответа; проверяется по контракту до регистрации мока
status HTTP-статус; он же сужает выбор варианта ответа
content_type сужает выбор варианта ответа
path_params закрепляемые сегменты маршрута; проверяются по схеме параметра
query_params сузить matcher по query-параметрам
headers сузить matcher по заголовкам запроса
history_callback вызвать consumer-hook после получения history и cleanup, например для Allure-вложения
response_headers заголовки ответа; Content-Type подставляется из контракта, если не задан
wait_for_requests сколько запросов дождаться перед выходом из блока
timeout таймаут ожидания, секунды (по умолчанию 5.0)

Жизненный цикл

До входа в блок (внутри mock(), ещё до регистрации мока в JJ):

  1. проверяется версия установленного JJ (проверенный диапазон — >=2.9,<3);
  2. проверяется, что все path_params описаны маршрутом и каждое значение валидно по схеме своего параметра;
  3. выбирается вариант ответа по status/content_type; неоднозначный выбор — ошибка, «молча взять первый» библиотека не умеет;
  4. тело ответа валидируется по JSON Schema контракта;
  5. тело ответа независимо валидируется по generated d42 — если extra [d42] установлен и для операции d42 сгенерирован. Два пути не дублируют друг друга: d42-проекция oneOf шире исходной семантики, и точность держит именно JSON Schema;
  6. проверяются диапазон статуса (100..599) и совпадение Content-Type ответа с контрактом.

Невалидное тело ответа падает здесь — до приложения оно не доезжает:

ResponseContractError: тело ответа 200:application/json: 'id' is a required property
  ожидалось: ['id', 'slug', 'title']
  фактически: {'nope': 1} [operation=ws2.addTicket, direction=response, ...]

На входе в блок: строятся matcher (метод, маршрут с подставленными path_params, query_params, headers) и ответ; мок регистрируется как disposable с prefetch_history.

На выходе из блока:

  1. если тело сценария не бросило исключение и задан wait_for_requests — дожидаемся запросов с timeout;
  2. мок снимается (cleanup/deregister) — всегда, даже если тело сценария упало;
  3. забирается история; она остаётся доступной через mock.history и mock.requests;
  4. если задан wait_for_requests, а перехвачено меньше — ошибка;
  5. каждый перехваченный запрос валидируется по контракту: метод, маршрут, path/query/header/cookie-параметры с их сериализацией и обязательностью, тело вместе с Content-Type.
RequestContractError: request #0: тело запроса обязательно, но запрос пришёл без тела
  ожидалось: тело ['application/json']
  фактически: пусто [operation=ws2.addTicket, direction=request, pointer=/body]

Если тело сценария бросило исключение, оно остаётся первичным: cleanup всё равно выполняется, а вторичные диагностики прикладываются к нему заметками (add_note, Python 3.11+) и всегда доступны через mock.diagnostics.

wait_for_requests — это не ассерт на количество

wait_for_requests=N решает ровно одну задачу: синхронизацию жизненного цикла. Он даёт асинхронному приложению время дослать запросы до того, как мок будет снят и история собрана. Проверяет он только нижнюю границу: «перехвачено не меньше N».

ContractMockError: ожидалось минимум 1 запрос(ов), перехвачено 0 [operation=ws2.addTicket]

Он не заменяет ассерт на точное количество вызовов: три запроса вместо одного wait_for_requests=1 пропустит молча. Cardinality проверяет тест:

async with operations.ws2.add_ticket.mock(response=body, wait_for_requests=1) as mock:
    await page.submit()

assert len(mock.history) == 1
request = mock.requests[0]
assert request.method == "POST"

Ограничения мока

  • Готовый чужой jj.Mocked библиотека не принимает: восстановить контракт интроспекцией matcher'а и response'а невозможно, поэтому мок всегда строится из OperationHandle.
  • В один ContractMock нельзя войти дважды — создайте новый.
  • Persistent-моки не поддерживаются (см. «Ограничения v0.1»).
  • Значение path_params, содержащее /, отклоняется: оно разбило бы маршрут на лишние сегменты.
  • Если выбран вариант ответа default, конкретный HTTP-статус нужно передать аргументом status.

8. Overlays: осмысленные данные без потери контракта

Сгенерированная схема описывает контракт, но не описывает осмысленные данные: schema.str в поле «название» даст "9-hK_2 0zQ", и по такому скриншоту тест не почитаешь. overlay_generators подменяет генератор отдельного листа, ничего не ломая в остальном контракте.

from d42 import schema

from geas import Direction
from geas.integrations.d42 import EACH, build_fixture, overlay_generators

op = operations.ws2.get_queue
variant = op.response(status=200)
generated = op.d42_schema(Direction.RESPONSE, export=variant.d42_export)

QueueDetailsSchema = overlay_generators(
    generated,
    {
        ("name",): schema.str("Отчёт за квартал"),
        ("groups", EACH, "title"): schema.str("Аналитика"),
    },
)

fixture = build_fixture(QueueDetailsSchema)
# {'name': 'Отчёт за квартал', 'groups': [{'title': 'Аналитика'}, {'title': 'Аналитика'}, ...]}

Инварианты:

  1. Подменяется только генератор листа. Структура (словари, списки) и обязательность ключей всегда остаются сгенерированными; ни один объект d42 не мутируется на месте.
  2. Путь проверяется при сборке overlay'я, а не при генерации. Поле переименовали в спецификации — падает импорт модуля с overlay'ями, а не тест через неделю.
  3. EACH — публичный маркер «каждый элемент массива»; вложенные массивы поддерживаются: ("a", EACH, "b", EACH, "c").
  4. Через nullable спуск прозрачен: если по пути стоит X | schema.none, overlay применяется к ветке X, а schema.none остаётся на месте — поле как было nullable, так и осталось.
  5. Совместимость проверяется настолько рано, насколько разрешима. Тип ручной схемы обязан совпасть с типом листа; если лист — union литералов (enum), ручной генератор обязан быть его подмножеством.

Что проверить заранее нельзя — pattern и границы (minLength, minimum, ...): это разрешимо только для конкретного значения. Поэтому build_fixture валидирует результат по исходному, до-overlay'ному контракту и падает ContractOverlayError, если ручной генератор вышел за его пределы. Реальные сообщения:

overlay /nope: ключа 'nope' нет в сгенерированной схеме. Доступны: 'groups', 'name'
overlay /groups: подменить можно только генератор листа, а здесь ListSchema —
  структура всегда остаётся сгенерированной
overlay /name: тип ручной схемы не совпадает с контрактом.
  Ожидалось: StrSchema; получено: IntSchema
значение, выданное ручным генератором overlay'я, нарушает сгенерированный контракт:
  Value <class 'str'> at _['name'] must have at least 1 element, but it has 0 elements

Результат overlay'я — обычная d42-схема: fake(), % (substitute) и make_required() работают на ней как на любой другой. Оговорка: % и make_required() строят новый объект и про запомненный исходный контракт не знают, поэтому порядок должен быть обратным — сначала make_required / %, потом overlay_generators.

Пути overlay'ев записаны в той же грамматике, что и contract path у waiver'ов: ("groups", EACH, "title") — это /groups/-/title.


9. Waivers и non_waivable

Waiver — единственный способ пропустить конструкцию, которую нормализация иначе отклонила бы. Он намеренно неудобен: обязательны владелец, причина, тикет, срок и expected_source — отпечаток исходного фрагмента спецификации.

Сообщение об ошибке печатает готовый рецепт, включая точное значение expected_source:

ошибка: format 'my-custom-tag' неизвестен для type='string'. ... (contract path /body/tag)
Если это осознанное исключение, добавьте в waivers.yaml:
  - operation: api.getDocumentTag
    direction: response
    json_pointer: /body/tag
    rule: allow_unknown_format
    expected_source: 77111c9dbf349c0c707b300581710f165b8d3a011a3764b9fd94e518f3a429a2
    reason: <зачем>
    owner: <кто отвечает>
    issue: <ссылка>
    expires_at: <YYYY-MM-DD>

Генерация падает, если waiver просрочен, выписан дальше policies.waiver_max_days, неполон, объявлен дважды, конфликтует с другим правилом на той же точке, ссылается на несуществующую операцию, не понадобился или устарел (исходный фрагмент изменился — expected_source больше не совпадает).

non_waivable — обратная сторона: свойства контракта, которые нельзя ослабить никаким waiver'ом. Объявляются в manifest у операции:

operations:
  ws2.addTicket:
    source: main
    operation_id: addTicket
    non_waivable:
      - {direction: response, json_pointer: /body/rc, rules: [required, non_null, non_empty_enum]}

Если waiver пересекается с non_waivable, генерация падает; если само свойство перестало выполняться (поле стало необязательным, стало nullable, потеряло enum) — падает тоже.

Полный формат, правила жизненного цикла и разобранный пример — docs/waivers.md.


10. Интеграция с CI

Одна команда:

- name: contract drift
  run: geas -m contracts/manifest.yaml check

check ничего не меняет в рабочем дереве: он рендерит набор в памяти и сравнивает с тем, что лежит на диске. Коды возврата стабильны и годятся для гейта: 0 — чисто, 1 — ошибка контракта (drift, binding, waiver, неподдержанная конструкция), 2 — ошибка использования CLI.

check ловит не только «забыли перегенерировать», но и всё, что ломает генерацию: просроченный waiver, изменившийся operationId, исчезнувший вариант ответа, новую неподдержанную конструкцию в спецификации.

Что печатается при расхождении:

generated-артефакты разошлись со спецификацией:
  отсутствует: contracts/ws2__add_ticket.json
  отличается:  operations.py
  лишний:      contracts/ws2__old_operation.json

Запустите 'geas update' и закоммитьте результат

Полезное дополнение — diff: он показывает, что именно изменилось в контракте, и отделяет семантику от оформления.

ws2.addTicket:
  [контракт] ~ /responses/0/schema/$defs/Ticket/properties/title/maxLength: 120 → 200

diff возвращает 1, если есть семантические изменения, и 0, если изменения только косметические (слаг, диалект, имена d42-модулей). --json даёт машиночитаемый вывод с тем же кодом возврата.

Если вы генерируете d42-артефакты, в CI-окружении должен стоять extra [d42] — иначе check и update упадут (см. следующий раздел).


11. Опциональные зависимости

Extra Что включает Что без него не работает
— ядро: manifest, нормализация, JSON Schema, артефакты, CLI, runtime-валидация —
[d42] d42>=2,<3 генерация и чтение d42-схем, build_fixture, overlay_generators
[jj] jj>=2.9,<3 OperationHandle.mock()
[all] оба
[dev] оба + pytest, ruff, mypy, build разработка самой библиотеки

Отсутствующий extra — это не ModuleNotFoundError из недр библиотеки, а MissingExtraError с точной командой установки. Три реальных сообщения:

MissingExtraError: operation-aware моки требует опциональной зависимости.
Установите: pip install 'geas[jj]'

MissingExtraError: generated d42-схемы требует опциональной зависимости.
Установите: pip install 'geas[d42]'

ошибка: генерация d42-схем для операций ws2.addTicket требует опциональной зависимости.
Установите: pip install 'geas[d42]'

MissingExtraError наследуется и от ContractError, и от ImportError, поэтому ловится любым из двух.

Проект, которому нужен только drift-check в CI, ставит библиотеку без extras: ядро и generated-реестр импортируются на голом окружении.

Ядро использует jsonschema[format-nongpl]: проверки URI/IRI и остальных стандартных форматов остаются включены, но установка не приносит устаревший GPL-пакет rfc3987. Это позволяет использовать библиотеку в проектах с запретом GPL runtime-зависимостей.


12. Матрица поддержки OpenAPI 3.0 / Swagger 2.0

Каждая конструкция имеет ровно один из трёх статусов: поддержана, отклоняется с диагностикой или не читается вовсе. Полная таблица — включая колонку про то, что выражается в d42-проекции, а что остаётся только на JSON-Schema-пути, — docs/support-matrix.md.

Коротко: поддержаны $ref (локальные и межфайловые внутри корня источника), allOf, oneOf, anyOf, discriminator, nullable / x-nullable, readOnly / writeOnly, enum, pattern, границы длины и чисел, uniqueItems, булев и типизированный additionalProperties, параметры в path/query/header/cookie с проверенной матрицей style/explode, collectionFormat в Swagger 2.0.

Отклоняются с диагностикой: not, if/then/else, const, patternProperties, propertyNames, contains, prefixItems, dependentSchemas, dependentRequired, unevaluatedProperties, unevaluatedItems, $defs, булевы схемы, type списком, style: deepObject / label / matrix, content вместо schema у параметра, allowReserved, in: formData, диапазоны статусов вида 2XX, внешние HTTP-$ref, $ref за пределы корня источника и неизвестный format (при policies.unknown_formats: reject).

oneOf в d42-проекции расширяется до schema.any (то есть до anyOf), потому что у d42 нет эксклюзивного объединения. Точная семантика oneOf и discriminator целиком держится на JSON Schema, и именно поэтому JSON-Schema-валидация выполняется всегда и не отключается.


13. OpenAPI 3.1 в v0.1 сознательно не поддерживается

OpenAPI 3.1 не является надмножеством 3.0: в нём удалён nullable, exclusiveMinimum/exclusiveMaximum стали числовыми, type может быть массивом, появились булевы схемы и $defs, а Schema Object — это полноценная JSON Schema 2020-12. Разбирать 3.1 правилами 3.0 значит молча потерять null в типах и неверно прочитать границы.

Поэтому документ с openapi: 3.1.x не обрабатывается «как получится», а отклоняется явной диагностикой:

ошибка: OpenAPI 3.1.0 не поддерживается в версии 0.1.
OpenAPI 3.1 не является надмножеством 3.0: в нём удалён 'nullable',
'exclusiveMinimum'/'exclusiveMaximum' стали числовыми, 'type' может быть массивом,
появились булевы схемы и '$defs'. Разбирать 3.1 правилами 3.0 значит молча потерять
контракт, поэтому библиотека отказывается это делать.
Адаптер 3.1 добавляется отдельно (dialects/openapi31.py) без изменений в ядре. [source=main]

Код возврата — 1. Адаптер 3.1 — это новый модуль в dialects/ и одна запись в реестре, без изменений в ядре, генераторе, runtime и CLI.


14. Тесты не ходят в сеть

Ни библиотека, ни её тесты не делают исходящих сетевых запросов.

  • Внешние $ref (http://, https://, любой scheme или netloc) запрещены всегда и отклоняются RefResolutionError.
  • Межфайловые $ref разрешаются только внутри явно заданного sources.<name>.root; выход за корень через .. или symlink отсекается после resolve().
  • Валидатор JSON Schema получает пустой referencing.Registry, чей retrieve всегда бросает исключение: попытка внешнего разрешения $ref превращается в ошибку контракта, а не в HTTP-запрос.
  • В тестах автоиспользуемая фикстура no_outbound_network патчит socket.socket.connect и socket.create_connection и разрешает только AF_UNIX и loopback (127.0.0.0/8, ::1) — их использует локальный HTTP-сервер моков. Попытка выйти наружу — это не «медленный тест», а дефект: где-то не сработал мок.

Синтетические спецификации для тестов лежат в tests/fixtures/specs/ в вымышленном домене; реальных сервисов, маршрутов и идентификаторов там нет.


15. Миграция существующих mocked_*-обёрток

Существующие обёртки остаются тонкими функциями поверх OperationHandle.mock() и мигрируют без изменения call sites:

def mocked_post_ticket(body, **kwargs):
    """Тонкая обёртка: сохраняет старую сигнатуру, внутри — generated handle."""
    return operations.ws2.add_ticket.mock(response=body, **kwargs)
async with mocked_post_ticket(body) as mock:  # ни один вызов не переписан
    ...

Рекомендуемая целевая форма — прямой generated handle:

async with operations.ws2.add_ticket.mock(response=body, wait_for_requests=1) as mock:
    ...

Пошаговый план (подключение спецификации, обёртки, перевод рукописных схем в overlay_generators над сгенерированными, подключение check в CI) — docs/migration.md.


Ограничения v0.1

  • Нет decorator API. Контракт подключается явным async with ...mock(...). Декоратор над сценарием отложен сознательно: он вынужден догадываться, куда отдать ContractMock, и создаёт второй путь валидации, который придётся держать в синхроне с основным.
  • Нет OpenAPI 3.1. Документ отклоняется явной диагностикой (раздел 13).
  • Нет persistent-моков. start() без обязательного «закрыть и провалидировать» позволил бы молча пропустить валидацию запросов. Мок регистрируется как disposable явно, а не по переменной окружения — чтобы поведение не зависело от настроек машины.
  • Рекурсивный контракт получает JSON Schema, но не получает d42. d42 строит схему «по значению», и рекурсия развернулась бы бесконечно. update печатает об этом строкой контракт рекурсивен, d42-схемы не генерируются (JSON Schema и валидация работают), а OperationHandle.d42_schema() для такой операции поднимает OperationLookupError.
  • update и check требуют extra [d42], если для операций включены d42-артефакты. Выключить их можно точечно ключом d42: false у операции в manifest либо флагом --no-d42 у geas add.
  • Параметры — только скаляры и массивы скаляров. Объекты в параметрах и вложенные массивы не сериализуются однозначно и отклоняются.
  • Тело — только JSON-совместимые media type (application/json и */+json; в Swagger 2.0 дополнительно */*). Остальные варианты помечаются как непредставимые, не попадают в контракт, но саму операцию не роняют.

Разработка

make install     # venv + editable-установка с dev-зависимостями
make lint        # ruff check + ruff format --check
make typecheck   # mypy strict
make test        # pytest (сеть не требуется и запрещена)
make check       # всё сразу

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

Release files for geas 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for geas 0.2.0
File Size Uploaded
geas-0.2.0.tar.gz 322.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for geas 0.2.0
File Interpreter ABI Platform
geas-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 500.4 kB

Release files / geas-0.2.0.tar.gz

Download URL geas-0.2.0.tar.gz
Size 322.2 kB
Tags Source
SHA-256 checksum
How to use checksums
e161387040b5eb56902a06c80c76e6d28f2d1c021d7b39fa8d258269a2e26cd7
BLAKE2b-256 checksum
How to use checksums
85d18119c68725ba30f0217c607263a2b2430700e36ffc9f1e534aeef3ce8fb7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.11

Release files / geas-0.2.0-py3-none-any.whl

Download URL geas-0.2.0-py3-none-any.whl
Size 178.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0aae02aa2ccd12ae24cfc6412a4b51b19c7c566e235ee58cc531dc2cae6dccf5
BLAKE2b-256 checksum
How to use checksums
4b357e61b1c22e781c05f9cf38b6586c825e0b40e38f8d523f9cf55a3cdc931d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.11

Release history Release notifications | RSS feed

0.4.0

2 release files

0.3.0

2 release files

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page