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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.3.2] - 2026-10-07

### Fixed

- Tracking timestamps in logistics objects and shipment objects now accept offset-free Bling values through the generated `BlingDatetime` type. Naive timestamps use `America/Sao_Paulo` (including historical daylight saving time); explicit offsets are preserved, and JSON serialization emits an ISO timestamp with its offset. The generator and shared model namespace use the same type, with portable timezone data supplied by `tzdata`.
- Tracking dates now normalize MySQL zero-date sentinels, empty strings and null to `None`, matching absent-date handling in `BlingDate`. This fixes a second failure confirmed in read-only live logistics objects. Other malformed dates still raise validation errors; the tracking timestamp key remains required, and unrelated DTO requirements are unchanged.

## [0.3.1] - 2026-10-06

### Fixed
Expand Down
17 changes: 17 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,23 @@ with BlingClient.from_env() as client:
print(contato.get("data", {}).get("nome"))
```

### Datas de rastreamento

Os campos `rastreamento.ultima_alteracao` de objetos e remessas usam
`BlingDatetime`. Datas sem fuso, como `"2026-10-07 11:24:02"`, são interpretadas
no fuso IANA `America/Sao_Paulo`, incluindo suas regras históricas de horário
de verão. Datas que já possuem fuso mantêm o offset informado pelo Bling.
Na serialização JSON, o resultado inclui o offset:
`"2026-10-07T11:24:02-03:00"`. A dependência `tzdata` fornece essas regras
também em sistemas sem uma base de fusos instalada.

Sentinelas de data ausente (`"0000-00-00 00:00:00"`, `"0000-00-00"`,
`"0001-01-01"`, `"0001-01-01 00:00:00"`), strings vazias e `null` são
representadas por `None`. `model_dump(mode="json")` preserva `null`;
`to_json_object()` omite esses valores. Outras datas malformadas continuam
causando erro de validação, e a chave `ultimaAlteracao` continua obrigatória
no objeto de rastreamento.

### Paginação

Endpoints de listagem aceitam `page` e `limit`:
Expand Down
65 changes: 65 additions & 0 deletions docs/response-validation-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Auditoria de validação de respostas — 2026-10-07

Esta investigação partiu da falha de `GET /logisticas/objetos/{idObjeto}` no
SDK 0.3.1: o Bling envia `rastreamento.ultimaAlteracao` sem offset, enquanto
o modelo gerado exigia `AwareDatetime`.

## Problemas confirmados e correções

| Problema | Evidência | Correção |
| --- | --- | --- |
| Timestamp sem fuso rejeitado | Reprodução com `2026-10-07 11:24:02`; o próprio OpenAPI contém exemplo sem offset | `BlingDatetime` interpreta valores sem fuso como `America/Sao_Paulo` e preserva offsets explícitos |
| Mesmo problema em remessas | `LogisticasRemessasRastreamentoDTO.ultimaAlteracao` também era `AwareDatetime` | Mesmo tipo aplicado pelo gerador; teste de leitura com rastreamento dentro de `objetos[]` |
| Data zerada rejeitada | Cinco objetos reais apresentaram sentinela de data zerada durante a investigação | Sentinelas de ausência viram `None`; datas malformadas comuns continuam falhando |

O tipo usa as regras IANA de São Paulo, incluindo o horário de verão histórico.
A dependência `tzdata` permite a mesma interpretação em sistemas sem base de
fusos. As chaves obrigatórias, os aliases e a proteção contra valores
conflitantes continuam sendo validados.

## Varredura dos modelos e do OpenAPI

- Os dois campos acima eram os únicos campos gerados com `AwareDatetime`.
- Há 52 propriedades com formato `date` nos componentes do OpenAPI. Seus
exemplos passaram pelo parser `BlingDate`, que já trata datas zeradas.
- Quatro propriedades usam o formato não padronizado `datetime`:
`NotasFiscaisDadosBaseDTO.dataEmissao`, `dataOperacao` e
`NotificacoesDadosBaseDTO.dataEnvio`, `dataLeitura`. O gerador as mantém como
texto; elas não exigem timezone e não foram convertidas nesta correção.
- Um teste percorre os exemplos `date-time` do OpenAPI e os valida com os
campos dos modelos gerados, para detectar novamente uma incompatibilidade
entre o exemplo oficial e o parser.

## Amostragem real, somente leitura

As consultas usaram `doppler run --project bling-sdk --config dev` e o
transporte do SDK com `rate_limit_max_requests=1` e
`rate_limit_period_seconds=2.1`. O limitador permaneceu habilitado, inclusive
para retries. Os relatórios exibiram somente rotas sem IDs, tipos e locais
de erros e resultados de validação. Payloads, identificadores, valores
financeiros e credenciais não foram salvos. As fixtures são sintéticas.

| Recurso | Resultado observado |
| --- | --- |
| Pedidos de venda, listagem e detalhe | Validação e serialização passaram |
| Produtos, listagem e detalhe | Validação e serialização passaram |
| Contatos, listagem e detalhe | Validação e serialização passaram |
| NF-e, listagem e detalhe | Validação e serialização passaram |
| Contas a pagar e a receber, listagem e detalhe | Validação e serialização passaram |
| Logísticas, listagem | Validação e serialização passaram |
| Objetos de logística | Oito leituras pelo método público passaram após as duas correções, com timestamp ausente representado por `None` |
| NFS-e | Listagem validada; sem registros para verificar detalhe |
| NFC-e | API retornou erro de autenticação/acesso; modelos não foram validados com payload real |
| Remessas por logística | API retornou erro de validação nas duas logísticas consultadas; detalhes permanecem cobertos por fixture sintética e teste do método público |

As amostras reais de rastreamento verificadas nesta execução tinham data
zerada. Datas sem fuso válidas foram verificadas com o valor relatado, os
exemplos oficiais e testes locais. A amostragem não prova que todos os
payloads possíveis de todos os endpoints sejam aceitos.

## Limite em relação ao Django

A atualização do Django foi excluída desta etapa a pedido do usuário. O SDK
corrige as duas falhas confirmadas de leitura. A persistência de NF-e/DANFE
em `sync_order()` e a recuperação de fichas antigas dependem do aplicativo
consumidor e não foram alteradas ou validadas aqui.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ nav:
- Autenticação: authentication.md
- Paginação: pagination.md
- Erros: errors.md
- Validação de respostas: response-validation-audit.md
- Recursos:
- resources/sales_orders.md
- resources/products.md
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ dependencies = [
"pydantic>=2.13.4,<3.0.0",
"pydantic-settings>=2.14.1,<3.0.0",
"python-dotenv>=1.2.2,<2.0.0",
"tzdata>=2025.2",
]

[project.urls]
Expand Down
21 changes: 10 additions & 11 deletions scripts/generate_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@
)
BUILTIN_TYPE_NAMES = {
"Any",
"AwareDatetime",
"BlingDate",
"BlingDatetime",
"BlingModel",
"Field",
"None",
Expand Down Expand Up @@ -181,7 +181,7 @@

# Type that replaces bare ``date`` in all generated annotations.
DATE_TYPE_REWRITE = "BlingDate"
DATE_IMPORT = "from bling_erp_api.models.fields import BlingDate"
DATE_IMPORT = "from bling_erp_api.models.fields import BlingDate, BlingDatetime"

# ``Field(...)`` keywords that affect the wire format. Inserted redeclarations
# of parent-inherited fields only clone the parent's ``Field(...)`` call when
Expand Down Expand Up @@ -604,10 +604,10 @@ def _schema_module_content(
"",
"from __future__ import annotations",
"",
"from bling_erp_api.models.fields import BlingDate",
DATE_IMPORT,
"from typing import TYPE_CHECKING, Any",
"",
"from pydantic import AliasChoices, AwareDatetime, Field, RootModel",
"from pydantic import AliasChoices, Field, RootModel",
"",
"from bling_erp_api.models.base import BlingModel",
]
Expand Down Expand Up @@ -653,11 +653,9 @@ def _schema_init_content(
"",
"from __future__ import annotations",
"",
"from bling_erp_api.models.fields import BlingDate",
DATE_IMPORT,
"from typing import Any",
"",
"from pydantic import AwareDatetime",
"",
]
for module, names in sorted(by_module.items()):
imports = ", ".join(names)
Expand All @@ -671,7 +669,7 @@ def _schema_init_content(
exports + "]",
"",
"_MODEL_NAMESPACE = {name: globals()[name] for name in __all__}",
"_MODEL_NAMESPACE.update({'Any': Any, 'AwareDatetime': AwareDatetime, 'BlingDate': BlingDate})",
"_MODEL_NAMESPACE.update({'Any': Any, 'BlingDate': BlingDate, 'BlingDatetime': BlingDatetime})",
"for _model in _MODEL_NAMESPACE.values():",
" if isinstance(_model, type) and hasattr(_model, 'model_rebuild'):",
" _model.model_rebuild(_types_namespace=_MODEL_NAMESPACE)",
Expand Down Expand Up @@ -912,10 +910,11 @@ def _remove_pass_if_empty(node: ast.ClassDef) -> None:


def _rewrite_date_types(node: ast.ClassDef) -> None:
"""Replace ``date`` with ``BlingDate`` in all field annotations."""
"""Use Bling date and datetime parsing in generated field annotations."""
replacements = {"date": "BlingDate", "AwareDatetime": "BlingDatetime"}
for stmt in ast.walk(node):
if isinstance(stmt, ast.Name) and stmt.id == "date":
stmt.id = "BlingDate"
if isinstance(stmt, ast.Name) and stmt.id in replacements:
stmt.id = replacements[stmt.id]


def _inherited_field_value(parent_decl: ast.AnnAssign | None, field_name: str) -> ast.expr:
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.1"
__version__ = "0.3.2"
38 changes: 37 additions & 1 deletion src/bling_erp_api/models/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

from __future__ import annotations

from datetime import date
from datetime import date, datetime
from typing import Annotated
from zoneinfo import ZoneInfo

from pydantic import AfterValidator, BeforeValidator
from pydantic.functional_serializers import PlainSerializer
from pydantic.functional_validators import PlainValidator

Expand Down Expand Up @@ -44,3 +46,37 @@ def _serialize_bling_date(value: date | None) -> str | None:
PlainValidator(_parse_bling_date),
PlainSerializer(_serialize_bling_date, return_type=str | None, when_used="json"),
]


def _normalize_bling_datetime(value: object) -> object:
"""Map Bling's absent timestamp sentinels to None before datetime parsing."""
if isinstance(value, str):
stripped = value.strip()
if not stripped or stripped in {
"0000-00-00",
"0000-00-00 00:00:00",
"0001-01-01",
"0001-01-01 00:00:00",
}:
return None
return stripped
return value


def _localize_bling_datetime(value: datetime | None) -> datetime | None:
"""Interpret offset-free Bling timestamps in America/Sao_Paulo.

Preserve explicit offsets and use the IANA timezone rules for naive values,
including historical daylight saving time. Invalid timestamps still fail
Pydantic's normal datetime validation before this validator is called.
"""
if value is not None and (value.tzinfo is None or value.utcoffset() is None):
return value.replace(tzinfo=ZoneInfo("America/Sao_Paulo"))
Comment thread
tempont marked this conversation as resolved.
Resolved
return value


BlingDatetime = Annotated[
Comment thread
tempont marked this conversation as resolved.
Resolved
datetime | None,
BeforeValidator(_normalize_bling_datetime),
AfterValidator(_localize_bling_datetime),
]
6 changes: 2 additions & 4 deletions src/bling_erp_api/models/generated/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,9 @@

from __future__ import annotations

from bling_erp_api.models.fields import BlingDate
from bling_erp_api.models.fields import BlingDate, BlingDatetime
from typing import Any

from pydantic import AwareDatetime

from .anuncios import (
AnunciosAtributoDTO,
AnunciosCategoriaDTO,
Expand Down Expand Up @@ -1484,7 +1482,7 @@
]

_MODEL_NAMESPACE = {name: globals()[name] for name in __all__}
_MODEL_NAMESPACE.update({"Any": Any, "AwareDatetime": AwareDatetime, "BlingDate": BlingDate})
_MODEL_NAMESPACE.update({"Any": Any, "BlingDate": BlingDate, "BlingDatetime": BlingDatetime})
for _model in _MODEL_NAMESPACE.values():
if isinstance(_model, type) and hasattr(_model, "model_rebuild"):
_model.model_rebuild(_types_namespace=_MODEL_NAMESPACE)
4 changes: 2 additions & 2 deletions src/bling_erp_api/models/generated/schemas/anuncios.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

from __future__ import annotations

from bling_erp_api.models.fields import BlingDate
from bling_erp_api.models.fields import BlingDate, BlingDatetime
from typing import TYPE_CHECKING, Any

from pydantic import AliasChoices, AwareDatetime, Field, RootModel
from pydantic import AliasChoices, Field, RootModel

from bling_erp_api.models.base import BlingModel

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

from __future__ import annotations

from bling_erp_api.models.fields import BlingDate
from bling_erp_api.models.fields import BlingDate, BlingDatetime
from typing import TYPE_CHECKING, Any

from pydantic import AliasChoices, AwareDatetime, Field, RootModel
from pydantic import AliasChoices, Field, RootModel

from bling_erp_api.models.base import BlingModel

Expand Down
4 changes: 2 additions & 2 deletions src/bling_erp_api/models/generated/schemas/borderos.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

from __future__ import annotations

from bling_erp_api.models.fields import BlingDate
from bling_erp_api.models.fields import BlingDate, BlingDatetime
from typing import TYPE_CHECKING, Any

from pydantic import AliasChoices, AwareDatetime, Field, RootModel
from pydantic import AliasChoices, Field, RootModel

from bling_erp_api.models.base import BlingModel

Expand Down
4 changes: 2 additions & 2 deletions src/bling_erp_api/models/generated/schemas/caixas_bancos.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

from __future__ import annotations

from bling_erp_api.models.fields import BlingDate
from bling_erp_api.models.fields import BlingDate, BlingDatetime
from typing import TYPE_CHECKING, Any

from pydantic import AliasChoices, AwareDatetime, Field, RootModel
from pydantic import AliasChoices, Field, RootModel

from bling_erp_api.models.base import BlingModel

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

from __future__ import annotations

from bling_erp_api.models.fields import BlingDate
from bling_erp_api.models.fields import BlingDate, BlingDatetime
from typing import TYPE_CHECKING, Any

from pydantic import AliasChoices, AwareDatetime, Field, RootModel
from pydantic import AliasChoices, Field, RootModel

from bling_erp_api.models.base import BlingModel

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

from __future__ import annotations

from bling_erp_api.models.fields import BlingDate
from bling_erp_api.models.fields import BlingDate, BlingDatetime
from typing import TYPE_CHECKING, Any

from pydantic import AliasChoices, AwareDatetime, Field, RootModel
from pydantic import AliasChoices, Field, RootModel

from bling_erp_api.models.base import BlingModel

Expand Down
4 changes: 2 additions & 2 deletions src/bling_erp_api/models/generated/schemas/canais_venda.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

from __future__ import annotations

from bling_erp_api.models.fields import BlingDate
from bling_erp_api.models.fields import BlingDate, BlingDatetime
from typing import TYPE_CHECKING, Any

from pydantic import AliasChoices, AwareDatetime, Field, RootModel
from pydantic import AliasChoices, Field, RootModel

from bling_erp_api.models.base import BlingModel

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

from __future__ import annotations

from bling_erp_api.models.fields import BlingDate
from bling_erp_api.models.fields import BlingDate, BlingDatetime
from typing import TYPE_CHECKING, Any

from pydantic import AliasChoices, AwareDatetime, Field, RootModel
from pydantic import AliasChoices, Field, RootModel

from bling_erp_api.models.base import BlingModel

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

from __future__ import annotations

from bling_erp_api.models.fields import BlingDate
from bling_erp_api.models.fields import BlingDate, BlingDatetime
from typing import TYPE_CHECKING, Any

from pydantic import AliasChoices, AwareDatetime, Field, RootModel
from pydantic import AliasChoices, Field, RootModel

from bling_erp_api.models.base import BlingModel

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

from __future__ import annotations

from bling_erp_api.models.fields import BlingDate
from bling_erp_api.models.fields import BlingDate, BlingDatetime
from typing import TYPE_CHECKING, Any

from pydantic import AliasChoices, AwareDatetime, Field, RootModel
from pydantic import AliasChoices, Field, RootModel

from bling_erp_api.models.base import BlingModel

Expand Down
Loading
Loading