Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ jobs:
strategy:
matrix:
python-version: ["3.12", "3.13"]
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v4
- name: Install uv
Expand All @@ -26,17 +28,19 @@ jobs:
- name: Install dependencies
run: uv sync --all-groups --python ${{ matrix.python-version }}
- name: Ruff check
run: uv run ruff check .
run: uv run --all-groups ruff check .
- name: Ruff format check
run: uv run ruff format --check .
run: uv run --all-groups ruff format --check .
- name: Basedpyright
run: uv run basedpyright
run: uv run --all-groups basedpyright
- name: Pytest
run: uv run python -m pytest
run: uv run --all-groups python -m pytest

codegen-drift:
name: Codegen Drift Check
runs-on: ubuntu-latest
env:
UV_PYTHON: "3.12"
steps:
- uses: actions/checkout@v4
- name: Install uv
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.3.1] - 2026-10-06

### Fixed

- CI quality and codegen jobs now keep their configured Python version instead of recreating the environment with the repository's default interpreter. Quality commands and Makefile gates explicitly retain all dependency groups, so tests that invoke the real model generator can find `datamodel-codegen`.
- NF-e and NFC-e read responses no longer require `contato.tipoPessoa` and `contato.contribuinte`: the official OpenAPI marks both fields `writeOnly`, so they are required only in requests. Generated response-only DTOs retain typed values when present and represent missing fields as `None`, omitted by `to_json_object()`. Detail reads, lists and pagination accept these responses; POST/PUT requirements, existing public methods, aliases and all unrelated endpoint contracts are preserved. The returned contact uses `NotasFiscaisContatoResponseDTO` instead of the shared request DTO.
- Model generation now projects required `writeOnly` fields and referencing component schemas only for responses, including operation metadata for pagination, without modifying the vendored OpenAPI or weakening request models. Inline compositions, array items, nested properties, reusable response components and inline/reusable callbacks are covered; request body components remain unchanged.

## [0.3.0] - 2026-09-29

### Breaking Changes
Expand Down
9 changes: 7 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,22 @@ Generated files live in:
Run the test suite:

```bash
uv run python -m pytest
uv run --all-groups python -m pytest
```

Or with coverage:

```bash
uv run python -m pytest --cov=bling_erp_api
uv run --all-groups python -m pytest --cov=bling_erp_api
```

**Important**: Tests must not make real API calls unless explicitly gated behind environment variables. Use `pytest-httpx` for HTTP mocking.

The test suite invokes the real model generator, so the `codegen` dependency
group must remain installed. `make check` includes all groups automatically.
To reproduce each CI interpreter locally, use `UV_PYTHON=3.12 make check` or
`UV_PYTHON=3.13 make check`; this overrides the repository's `.python-version`.

## Adding a New Endpoint

Follow the checklist in `AGENTS.md` (located in the repository root) for adding new endpoints. The key steps are:
Expand Down
13 changes: 7 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
.PHONY: check build codegen codegen-check deploy sync

UV := uv
UV_RUN := $(UV) run --all-groups
GENERATED_DIRS := src/bling_erp_api/models/generated src/bling_erp_api/contracts/generated docs/resources
UNTRACKED_GENERATED := git ls-files --others --exclude-standard -- $(GENERATED_DIRS)

sync:
$(UV) sync --all-groups

check: sync
$(UV) run ruff check .
$(UV) run ruff format --check .
$(UV) run basedpyright
$(UV) run python -m pytest
$(UV_RUN) ruff check .
$(UV_RUN) ruff format --check .
$(UV_RUN) basedpyright
$(UV_RUN) python -m pytest

codegen:
$(UV) run --group codegen python scripts/generate_models.py
$(UV) run python scripts/generate_openapi_contracts.py
$(UV_RUN) python scripts/generate_models.py
$(UV_RUN) python scripts/generate_openapi_contracts.py

codegen-check:
@if $(UNTRACKED_GENERATED) | grep -q .; then \
Expand Down
26 changes: 26 additions & 0 deletions docs/api-reference/nfe.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
# Notas Fiscais (NF-e)

Nas respostas de leitura, `contato.tipo_pessoa` e `contato.contribuinte` podem
estar ausentes. O OpenAPI do Bling marca `tipoPessoa` e `contribuinte` como
`writeOnly`: sua obrigatoriedade aplica-se à requisição, conforme a
[especificação OpenAPI 3.0](https://swagger.io/specification/v3/#schema-object).
O SDK usa `NotasFiscaisContatoResponseDTO` nessas respostas, preserva os valores
quando presentes e representa a ausência como `None`. `to_json_object()` omite
esses campos ausentes; não preenche valores fiscais por conta própria.

Essa distinção também vale para a listagem, a paginação e as leituras de NFC-e
que reutilizam o mesmo schema. `NotasFiscaisContatoDTO` e os modelos de criação e
alteração continuam exigindo ambos os campos. Os métodos públicos e seus aliases
mantêm as assinaturas; o tipo do contato retornado passa a ser a variante de
resposta. `nome` e `numero_documento` continuam obrigatórios, e os aliases e a
rejeição de chaves conflitantes permanecem ativos.

A geração faz essa projeção numa cópia temporária do OpenAPI, sem alterar
`specs/bling-openapi-reference.json`. A revisão de campos `writeOnly` obrigatórios
do schema versionado identificou somente esses dois campos; os demais campos
`writeOnly` já eram opcionais nas respostas. Contratos de outros recursos e
todos os request bodies são preservados por testes de regressão.

A projeção também percorre schemas inline aninhados (composições, itens de
arrays e propriedades), componentes reutilizáveis de resposta e respostas de
callbacks. As exigências são removidas somente no objeto que declara o campo;
os objetos pais, exemplos e componentes `requestBodies` permanecem intactos.

::: bling_erp_api.resources.nfe.NfeResource
options:
members:
Expand Down
153 changes: 149 additions & 4 deletions scripts/generate_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import re
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, cast
Expand Down Expand Up @@ -219,9 +220,15 @@ class FieldDoc:
def main() -> None:
"""Generate canonical schemas, resource reexports, and operation model metadata."""
payload = cast("dict[str, object]", json.loads(SPEC_PATH.read_text(encoding="utf-8")))
generation_payload = _response_generation_spec(payload)
_prepare_generated_dirs()

_run_datamodel_codegen()
# Required writeOnly properties apply to requests only (OpenAPI 3.0).
# Keep the vendored spec intact and project response-only schemas locally.
with tempfile.TemporaryDirectory() as directory:
input_path = Path(directory) / "openapi.json"
input_path.write_text(json.dumps(generation_payload), encoding="utf-8")
_run_datamodel_codegen(input_path)
class_order, class_nodes = _schema_class_nodes()

# Inject response data wrapper models (new classes or body overwrites).
Expand All @@ -230,7 +237,7 @@ def main() -> None:
_APPLIED_FIELD_OVERRIDES.clear()

class_names = set(class_order)
all_contracts = _contracts_by_module(payload)
all_contracts = _contracts_by_module(generation_payload)
class_modules = _schema_class_modules(class_order, all_contracts)

_write_schema_package(class_order, class_nodes, class_modules)
Expand Down Expand Up @@ -276,11 +283,149 @@ def _prepare_generated_dirs() -> None:
SCHEMAS_PACKAGE_DIR.mkdir(parents=True)


def _run_datamodel_codegen() -> None:
def _response_generation_spec(payload: Mapping[str, object]) -> dict[str, object]:
"""Separate required writeOnly fields from response validation.

Clone only affected component schemas and their referencing ancestors.
Requests and the public shared DTOs retain the original requirements.
Response variants keep the fields typed for compatibility when present,
but do not require them or supply wire values when they are absent.
"""
result = copy.deepcopy(dict(payload))
components = cast("dict[str, object]", result["components"])
schemas = cast("dict[str, dict[str, object]]", components["schemas"])
affected = {name for name, schema in schemas.items() if _has_required_write_only_fields(schema)}
while True:
ancestors = {
name for name, schema in schemas.items() if _schema_ref_names(schema) & affected
}
if ancestors <= affected:
break
affected.update(ancestors)

replacements = {name: f"{name.removesuffix('DTO')}ResponseDTO" for name in sorted(affected)}
for name, replacement in replacements.items():
if replacement in schemas:
msg = f"Response schema projection collides with {replacement}"
raise ValueError(msg)
schemas[replacement] = _project_response_schema(schemas[name], replacements)

paths = cast("dict[str, dict[str, object]]", result["paths"])
for path in paths.values():
_project_path_responses(path, replacements)
responses = cast("dict[str, dict[str, object]]", components.get("responses", {}))
for name, response in responses.items():
responses[name] = _project_response_object(response, replacements)
callbacks = cast("dict[str, dict[str, object]]", components.get("callbacks", {}))
for callback in callbacks.values():
_project_callback_responses(callback, replacements)
return result


def _required_write_only_fields(schema: Mapping[str, object]) -> set[str]:
properties = cast("dict[str, dict[str, object]]", schema.get("properties", {}))
required = cast("list[str]", schema.get("required", []))
return {name for name in required if properties.get(name, {}).get("writeOnly") is True}
Comment thread
tempont marked this conversation as resolved.
Resolved


def _nested_schemas(schema: Mapping[str, object]) -> Iterable[dict[str, object]]:
"""Yield inline subschemas without traversing examples or other arbitrary data."""
properties = cast("dict[str, dict[str, object]]", schema.get("properties", {}))
yield from properties.values()
for key in ("items", "additionalProperties", "not"):
value = schema.get(key)
if isinstance(value, dict):
yield cast("dict[str, object]", value)
for key in ("allOf", "anyOf", "oneOf"):
yield from cast("list[dict[str, object]]", schema.get(key, []))


def _has_required_write_only_fields(schema: Mapping[str, object]) -> bool:
return bool(_required_write_only_fields(schema)) or any(
_has_required_write_only_fields(child) for child in _nested_schemas(schema)
)


def _project_response_schema(
schema: Mapping[str, object], replacements: Mapping[str, str]
) -> dict[str, object]:
result = copy.deepcopy(dict(schema))
_rewrite_response_schema(result, replacements)
return result


def _rewrite_response_schema(schema: dict[str, object], replacements: Mapping[str, str]) -> None:
omitted = _required_write_only_fields(schema)
if omitted:
required = cast("list[str]", schema["required"])
schema["required"] = [field for field in required if field not in omitted]
reference = schema.get("$ref")
if isinstance(reference, str) and reference.startswith("#/components/schemas/"):
name = reference.rsplit("/", 1)[1]
if name in replacements:
schema["$ref"] = f"#/components/schemas/{replacements[name]}"
for child in _nested_schemas(schema):
_rewrite_response_schema(child, replacements)


def _project_response_object(
response: Mapping[str, object], replacements: Mapping[str, str]
) -> dict[str, object]:
result = copy.deepcopy(dict(response))
content = cast("dict[str, dict[str, object]]", result.get("content", {}))
for media_type in content.values():
schema = media_type.get("schema")
if isinstance(schema, dict):
media_type["schema"] = _project_response_schema(
cast("dict[str, object]", schema), replacements
)
headers = cast("dict[str, dict[str, object]]", result.get("headers", {}))
for header in headers.values():
schema = header.get("schema")
if isinstance(schema, dict):
header["schema"] = _project_response_schema(
cast("dict[str, object]", schema), replacements
)
return result


def _project_path_responses(path: dict[str, object], replacements: Mapping[str, str]) -> None:
for method in ("get", "put", "post", "delete", "options", "head", "patch", "trace"):
value = path.get(method)
if not isinstance(value, dict):
continue
operation = cast("dict[str, object]", value)
responses = cast("dict[str, dict[str, object]]", operation.get("responses", {}))
for status, response in responses.items():
responses[status] = _project_response_object(response, replacements)
callbacks = cast("dict[str, dict[str, object]]", operation.get("callbacks", {}))
for callback in callbacks.values():
_project_callback_responses(callback, replacements)


def _project_callback_responses(
callback: dict[str, object], replacements: Mapping[str, str]
) -> None:
for expression, value in callback.items():
if expression != "$ref" and isinstance(value, dict):
_project_path_responses(cast("dict[str, object]", value), replacements)


def _schema_ref_names(schema: Mapping[str, object]) -> set[str]:
reference = schema.get("$ref")
names = set[str]()
if isinstance(reference, str) and reference.startswith("#/components/schemas/"):
names.add(reference.rsplit("/", 1)[1])
for child in _nested_schemas(schema):
names.update(_schema_ref_names(child))
return names


def _run_datamodel_codegen(input_path: Path) -> None:
command = [
_datamodel_codegen_bin(),
"--input",
str(SPEC_PATH),
str(input_path),
"--input-file-type",
"openapi",
"--openapi-scopes",
Expand Down
2 changes: 1 addition & 1 deletion src/bling_erp_api/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Package version."""

__version__ = "0.3.0"
__version__ = "0.3.1"
8 changes: 4 additions & 4 deletions src/bling_erp_api/contracts/generated/_operation_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,7 @@
"path": "/nfce",
"pattern": r"^/nfce/?$",
"response_model": "bling_erp_api.models.generated.schemas.notas_fiscais_consumidor.NfceGetResponse200",
"response_item_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NotasFiscaisDadosBaseDTO",
"response_item_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NotasFiscaisDadosBaseResponseDTO",
},
{
"resource_class": "NfeResource",
Expand Down Expand Up @@ -667,7 +667,7 @@
"path": "/nfe",
"pattern": r"^/nfe/?$",
"response_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NfeGetResponse200",
"response_item_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NotasFiscaisDadosBaseDTO",
"response_item_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NotasFiscaisDadosBaseResponseDTO",
},
{
"resource_class": "NfeResource",
Expand Down Expand Up @@ -1675,7 +1675,7 @@
"path": "/nfce",
"pattern": r"^/nfce/?$",
"response_model": "bling_erp_api.models.generated.schemas.notas_fiscais_consumidor.NfceGetResponse200",
"response_item_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NotasFiscaisDadosBaseDTO",
"response_item_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NotasFiscaisDadosBaseResponseDTO",
},
{
"resource_class": "NfceResource",
Expand Down Expand Up @@ -1774,7 +1774,7 @@
"path": "/nfe",
"pattern": r"^/nfe/?$",
"response_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NfeGetResponse200",
"response_item_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NotasFiscaisDadosBaseDTO",
"response_item_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NotasFiscaisDadosBaseResponseDTO",
},
{
"resource_class": "NfceResource",
Expand Down
6 changes: 6 additions & 0 deletions src/bling_erp_api/models/aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
NfePostResponse201,
NotaFiscalResponsePOST,
NotasFiscaisDadosBaseDTO,
NotasFiscaisDadosBaseResponseDTO,
)

# NFSe
Expand Down Expand Up @@ -150,6 +151,9 @@
VendasResponsePOSTPUT,
)

# Invoice response models
from bling_erp_api.models.generated.schemas.notas_fiscais import NotasFiscaisContatoResponseDTO

# Situations
from bling_erp_api.models.generated.situacoes import (
SituacoesDadosDTO,
Expand Down Expand Up @@ -241,7 +245,9 @@
"NfsePostRequest",
"NfsePostResponse201",
"NotaFiscalResponsePOST",
"NotasFiscaisContatoResponseDTO",
"NotasFiscaisDadosBaseDTO",
"NotasFiscaisDadosBaseResponseDTO",
"NotasServicosDadosBaseDTO",
"NotasServicosDadosDTO",
"NotasServicosResponsePOSTPUT",
Expand Down
2 changes: 2 additions & 0 deletions src/bling_erp_api/models/generated/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,9 @@
"NotaFiscalResponsePOST",
"NotasFiscaisContatoDTO",
"NotasFiscaisContatoEnderecoDTO",
"NotasFiscaisContatoResponseDTO",
"NotasFiscaisDadosBaseDTO",
"NotasFiscaisDadosBaseResponseDTO",
"NotasFiscaisDadosGetDTO",
"NotasFiscaisDadosPostDTO",
"NotasFiscaisDocumentoDTO",
Expand Down
Loading
Loading