Metadata-Version: 2.5
Name: geas
Version: 0.2.0
Summary: Генерация тестовых контрактов, JSON Schema, d42-схем и operation-aware моков из checked-in OpenAPI
Project-URL: Homepage, https://github.com/teka1905/geas
Project-URL: Changelog, https://github.com/teka1905/geas/blob/main/CHANGELOG.md
Author: geas contributors
License:                                  Apache License
                                   Version 2.0, January 2004
                                http://www.apache.org/licenses/
        
           TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
        
           1. Definitions.
        
              "License" shall mean the terms and conditions for use, reproduction,
              and distribution as defined by Sections 1 through 9 of this document.
        
              "Licensor" shall mean the copyright owner or entity authorized by
              the copyright owner that is granting the License.
        
              "Legal Entity" shall mean the union of the acting entity and all
              other entities that control, are controlled by, or are under common
              control with that entity. For the purposes of this definition,
              "control" means (i) the power, direct or indirect, to cause the
              direction or management of such entity, whether by contract or
              otherwise, or (ii) ownership of fifty percent (50%) or more of the
              outstanding shares, or (iii) beneficial ownership of such entity.
        
              "You" (or "Your") shall mean an individual or Legal Entity
              exercising permissions granted by this License.
        
              "Source" form shall mean the preferred form for making modifications,
              including but not limited to software source code, documentation
              source, and configuration files.
        
              "Object" form shall mean any form resulting from mechanical
              transformation or translation of a Source form, including but
              not limited to compiled object code, generated documentation,
              and conversions to other media types.
        
              "Work" shall mean the work of authorship, whether in Source or
              Object form, made available under the License, as indicated by a
              copyright notice that is included in or attached to the work
              (an example is provided in the Appendix below).
        
              "Derivative Works" shall mean any work, whether in Source or Object
              form, that is based on (or derived from) the Work and for which the
              editorial revisions, annotations, elaborations, or other modifications
              represent, as a whole, an original work of authorship. For the purposes
              of this License, Derivative Works shall not include works that remain
              separable from, or merely link (or bind by name) to the interfaces of,
              the Work and Derivative Works thereof.
        
              "Contribution" shall mean any work of authorship, including
              the original version of the Work and any modifications or additions
              to that Work or Derivative Works thereof, that is intentionally
              submitted to Licensor for inclusion in the Work by the copyright owner
              or by an individual or Legal Entity authorized to submit on behalf of
              the copyright owner. For the purposes of this definition, "submitted"
              means any form of electronic, verbal, or written communication sent
              to the Licensor or its representatives, including but not limited to
              communication on electronic mailing lists, source code control systems,
              and issue tracking systems that are managed by, or on behalf of, the
              Licensor for the purpose of discussing and improving the Work, but
              excluding communication that is conspicuously marked or otherwise
              designated in writing by the copyright owner as "Not a Contribution."
        
              "Contributor" shall mean Licensor and any individual or Legal Entity
              on behalf of whom a Contribution has been received by Licensor and
              subsequently incorporated within the Work.
        
           2. Grant of Copyright License. Subject to the terms and conditions of
              this License, each Contributor hereby grants to You a perpetual,
              worldwide, non-exclusive, no-charge, royalty-free, irrevocable
              copyright license to reproduce, prepare Derivative Works of,
              publicly display, publicly perform, sublicense, and distribute the
              Work and such Derivative Works in Source or Object form.
        
           3. Grant of Patent License. Subject to the terms and conditions of
              this License, each Contributor hereby grants to You a perpetual,
              worldwide, non-exclusive, no-charge, royalty-free, irrevocable
              (except as stated in this section) patent license to make, have made,
              use, offer to sell, sell, import, and otherwise transfer the Work,
              where such license applies only to those patent claims licensable
              by such Contributor that are necessarily infringed by their
              Contribution(s) alone or by combination of their Contribution(s)
              with the Work to which such Contribution(s) was submitted. If You
              institute patent litigation against any entity (including a
              cross-claim or counterclaim in a lawsuit) alleging that the Work
              or a Contribution incorporated within the Work constitutes direct
              or contributory patent infringement, then any patent licenses
              granted to You under this License for that Work shall terminate
              as of the date such litigation is filed.
        
           4. Redistribution. You may reproduce and distribute copies of the
              Work or Derivative Works thereof in any medium, with or without
              modifications, and in Source or Object form, provided that You
              meet the following conditions:
        
              (a) You must give any other recipients of the Work or
                  Derivative Works a copy of this License; and
        
              (b) You must cause any modified files to carry prominent notices
                  stating that You changed the files; and
        
              (c) You must retain, in the Source form of any Derivative Works
                  that You distribute, all copyright, patent, trademark, and
                  attribution notices from the Source form of the Work,
                  excluding those notices that do not pertain to any part of
                  the Derivative Works; and
        
              (d) If the Work includes a "NOTICE" text file as part of its
                  distribution, then any Derivative Works that You distribute must
                  include a readable copy of the attribution notices contained
                  within such NOTICE file, excluding those notices that do not
                  pertain to any part of the Derivative Works, in at least one
                  of the following places: within a NOTICE text file distributed
                  as part of the Derivative Works; within the Source form or
                  documentation, if provided along with the Derivative Works; or,
                  within a display generated by the Derivative Works, if and
                  wherever such third-party notices normally appear. The contents
                  of the NOTICE file are for informational purposes only and
                  do not modify the License. You may add Your own attribution
                  notices within Derivative Works that You distribute, alongside
                  or as an addendum to the NOTICE text from the Work, provided
                  that such additional attribution notices cannot be construed
                  as modifying the License.
        
              You may add Your own copyright statement to Your modifications and
              may provide additional or different license terms and conditions
              for use, reproduction, or distribution of Your modifications, or
              for any such Derivative Works as a whole, provided Your use,
              reproduction, and distribution of the Work otherwise complies with
              the conditions stated in this License.
        
           5. Submission of Contributions. Unless You explicitly state otherwise,
              any Contribution intentionally submitted for inclusion in the Work
              by You to the Licensor shall be under the terms and conditions of
              this License, without any additional terms or conditions.
              Notwithstanding the above, nothing herein shall supersede or modify
              the terms of any separate license agreement you may have executed
              with Licensor regarding such Contributions.
        
           6. Trademarks. This License does not grant permission to use the trade
              names, trademarks, service marks, or product names of the Licensor,
              except as required for reasonable and customary use in describing the
              origin of the Work and reproducing the content of the NOTICE file.
        
           7. Disclaimer of Warranty. Unless required by applicable law or
              agreed to in writing, Licensor provides the Work (and each
              Contributor provides its Contributions) on an "AS IS" BASIS,
              WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
              implied, including, without limitation, any warranties or conditions
              of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
              PARTICULAR PURPOSE. You are solely responsible for determining the
              appropriateness of using or redistributing the Work and assume any
              risks associated with Your exercise of permissions under this License.
        
           8. Limitation of Liability. In no event and under no legal theory,
              whether in tort (including negligence), contract, or otherwise,
              unless required by applicable law (such as deliberate and grossly
              negligent acts) or agreed to in writing, shall any Contributor be
              liable to You for damages, including any direct, indirect, special,
              incidental, or consequential damages of any character arising as a
              result of this License or out of the use or inability to use the
              Work (including but not limited to damages for loss of goodwill,
              work stoppage, computer failure or malfunction, or any and all
              other commercial damages or losses), even if such Contributor
              has been advised of the possibility of such damages.
        
           9. Accepting Warranty or Additional Liability. While redistributing
              the Work or Derivative Works thereof, You may choose to offer,
              and charge a fee for, acceptance of support, warranty, indemnity,
              or other liability obligations and/or rights consistent with this
              License. However, in accepting such obligations, You may act only
              on Your own behalf and on Your sole responsibility, not on behalf
              of any other Contributor, and only if You agree to indemnify,
              defend, and hold each Contributor harmless for any liability
              incurred by, or claims asserted against, such Contributor by reason
              of your accepting any such warranty or additional liability.
        
           END OF TERMS AND CONDITIONS
        
           APPENDIX: How to apply the Apache License to your work.
        
              To apply the Apache License to your work, attach the following
              boilerplate notice, with the fields enclosed by brackets "[]"
              replaced with your own identifying information. (Don't include
              the brackets!)  The text should be enclosed in the appropriate
              comment syntax for the file format. We also recommend that a
              file or class name and description of purpose be included on the
              same "printed page" as the copyright notice for easier
              identification within third-party archives.
        
           Copyright [yyyy] [name of copyright owner]
        
           Licensed under the Apache License, Version 2.0 (the "License");
           you may not use this file except in compliance with the License.
           You may obtain a copy of the License at
        
               http://www.apache.org/licenses/LICENSE-2.0
        
           Unless required by applicable law or agreed to in writing, software
           distributed under the License is distributed on an "AS IS" BASIS,
           WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
           See the License for the specific language governing permissions and
           limitations under the License.
License-File: LICENSE
Keywords: contract-testing,d42,fixtures,jj,json-schema,mocks,openapi,swagger
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Testing
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: jsonschema[format-nongpl]<5,>=4
Requires-Dist: pyyaml<7,>=6
Provides-Extra: all
Requires-Dist: d42<3,>=2; extra == 'all'
Requires-Dist: jj<3,>=2.9; extra == 'all'
Provides-Extra: d42
Requires-Dist: d42<3,>=2; extra == 'd42'
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: d42<3,>=2; extra == 'dev'
Requires-Dist: jj<3,>=2.9; extra == 'dev'
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest-asyncio<2,>=0.24; extra == 'dev'
Requires-Dist: pytest<9,>=8; extra == 'dev'
Requires-Dist: ruff>=0.9; extra == 'dev'
Requires-Dist: twine>=5; extra == 'dev'
Requires-Dist: types-jsonschema; extra == 'dev'
Requires-Dist: types-pyyaml; extra == 'dev'
Provides-Extra: jj
Requires-Dist: jj<3,>=2.9; extra == 'jj'
Description-Content-Type: text/markdown

# 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](docs/waivers.md).

---

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

### Установка

```bash
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

```bash
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`:

```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` — что вообще есть в источнике

```bash
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

```bash
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` — сгенерировать артефакты

```bash
geas -m manifest.yaml update
```

```
обновлено файлов: 8 в /path/to/demo/generated
```

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

```bash
geas -m manifest.yaml check
```

```
артефакты актуальны: 8 файл(ов)
```

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

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

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

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

```bash
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](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](docs/adr/0001-architecture.md).

---

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

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

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

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

```yaml
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.

```yaml
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](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 на расхождении.

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

```python
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)  # человекочитаемая форма контракта строкой
```

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

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

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

```python
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-файл глазами, выбранное
тело запроса и выбранный вариант ответа знают свои координаты:

```python
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()`; метод **возвращает строку** и ничего не печатает
и не перегенерирует:

```python
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](docs/cli.md#show).

---

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

```python
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`.

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

7. если тело сценария не бросило исключение и задан `wait_for_requests` — дожидаемся
   запросов с `timeout`;
8. мок снимается (cleanup/deregister) — всегда, даже если тело сценария упало;
9. забирается история; она остаётся доступной через `mock.history` и `mock.requests`;
10. если задан `wait_for_requests`, а перехвачено меньше — ошибка;
11. **каждый** перехваченный запрос валидируется по контракту: метод, маршрут,
    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 проверяет тест:

```python
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` подменяет генератор **отдельного листа**, ничего не ломая
в остальном контракте.

```python
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 у операции:

```yaml
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](docs/waivers.md).

---

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

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

```yaml
- 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](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**:

```python
def mocked_post_ticket(body, **kwargs):
    """Тонкая обёртка: сохраняет старую сигнатуру, внутри — generated handle."""
    return operations.ws2.add_ticket.mock(response=body, **kwargs)
```

```python
async with mocked_post_ticket(body) as mock:  # ни один вызов не переписан
    ...
```

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

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

Пошаговый план (подключение спецификации, обёртки, перевод рукописных схем в
`overlay_generators` над сгенерированными, подключение `check` в CI) —
[docs/migration.md](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 дополнительно `*/*`). Остальные варианты помечаются как непредставимые, не попадают
  в контракт, но саму операцию не роняют.

---

## Разработка

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

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

- [docs/cli.md](docs/cli.md) — команды, флаги, коды возврата
- [docs/waivers.md](docs/waivers.md) — формат waiver'ов, жизненный цикл, разобранный пример
- [docs/support-matrix.md](docs/support-matrix.md) — матрица поддержки OpenAPI 3.0 / Swagger 2.0
- [docs/migration.md](docs/migration.md) — миграция существующего E2E-проекта
- [docs/adr/0001-architecture.md](docs/adr/0001-architecture.md) — архитектурные решения
- [CHANGELOG.md](CHANGELOG.md) — история изменений
