Complementa Arquitectura. Referencias al código apuntan a
developen GitHub.
Fierro es una app cliente/servidor donde el cliente desktop (wxPython) y el webclient (Flask) invocan funciones en el server. Los parámetros y retornos son objetos Python del dominio (Book, Provider, SaleBill, …) que ningún protocolo de red estándar puede serializar directamente.
El marshalling convierte esos objetos a tipos primitivos (listas, dicts, strings, números) para transportarlos y los reconstruye del lado receptor. Sin él, habría que picar esa serialización a mano para cada tipo y cada método — exactamente lo que hace el sistema, pero generado automáticamente desde XML.
Hay una fuente de verdad en XML desde la que se genera código Python:
colofon/server/services.xml ← contrato de métodos remotos
colofon/client/ipc/modulesConfig.xml ← mapa clase-XML → factory Python (cliente)
colofon/server/ipc/modulesConfig.xml ← idem lado servidor
colofon/client/ipc/lazyClasses.xml ← clases de referencia lazy
│
▼ inv make-marshalling (makeMarshalling.sh dentro del container)
fierrotools/objectTransfer/
generateMarshallings.py ← genera *Marshalling.py
generateServices.py ← genera services.py
│
▼
colofon/client/ipc/*Marshalling.py ← generados, .gitignore, NO editar
colofon/server/ipc/*Marshalling.py ← generados, .gitignore, NO editar
colofon/client/ipc/services.py ← generado
colofon/server/ipc/services.py ← generado
colofon/client/ipc/lazyClasses.py ← generado
colofon/server/ipc/lazyClasses.py ← generado
Si se cambia una clase de dominio o la firma de un método sin regenerar, cliente y server quedan fuera de sincronía silenciosamente.
Cada objeto puede viajar de tres formas según lo que necesite el método:
flowchart TD
obj["Objeto dominio\n(ej: Book con 50 attrs)"]
obj --> thin["**ThinRef**\nSolo clave primaria\n─────────────────\nmarshall: identityFn(obj.id)\nunmarshall: BookThinRef(id=…)\nLazy load al primer acceso"]
obj --> gross["**GrossRef**\nSolo atributos cargados\nen __dict__\n─────────────────\nmarshall: {k:v for k in obj.__dict__}\nunmarshall: BookGrossRef(**dict)\nEstado parcial posible"]
obj --> byval["**ByValue**\nTodos los atributos\ndefinidos en el XML\n─────────────────\nmarshall: [attr1, attr2, …]\nunmarshall: Book(attr1=…, …)\nSnapshot completo, seguro"]
ThinRef: transporta solo la PK. El receptor crea un stub que llama al manager la primera vez que se accede a cualquier atributo (ver lazyClasses.py).
GrossRef: transporta solo los atributos que están en obj.__dict__ al momento de serializar. No garantiza completitud — es el modo más peligroso para el caché.
ByValue: transporta todos los atributos declarados en el XML. Es el modo seguro y explícito; usado para objetos editados que van del cliente al server.
sequenceDiagram
participant C as Cliente (wxPython)
participant SM as services.py (cliente)
participant SP as serverProxy
participant DS as Dispatcher (server)
participant MM as Manager (server)
C->>SM: BookManager.findByPrimaryKey(42)
note over SM: services["/book"].methods["findByPrimaryKey"]
SM->>SM: parámetro marshallado: 42 → 42 (identityFn)
SM->>SP: run("/book", "findByPrimaryKey", [42])
SP->>DS: POST /run/book/findByPrimaryKey/ {params:[42]}
DS->>MM: manager.findByPrimaryKey(42)
MM-->>DS: Book(id=42, title="…", …)
DS->>DS: marshallBookGrossRef(book) → {"id":42,"title":"…",…}
DS-->>SP: {"id":42,"title":"…",…}
SP->>SM: unmarshallBookGrossRef({"id":42,…})
SM-->>C: BookGrossRef(id=42, title="…")
Cada llamada involucra cuatro conversiones (marshal params + marshal retorno + unmarshal retorno) definidas por el XML.
Pyro4 es el framework RPC que maneja la capa de transporte. En Fierro usa msgpack como serializador de red:
# colofon/client/ipc/serverProxy.py
Pyro4.config.SERIALIZER = "msgpack"
Pyro4.config.MAX_MESSAGE_SIZE = 1024 * 1024 * 1024 # 1 GB — monkeypatch necesarioPyro no conoce las clases de dominio. Solo serializa los primitivos que el marshalling produce. El flujo real es:
Objeto dominio → marshallFn → dict/list de primitivos → msgpack → bytes → red
↑
Pyro4 serializa esto
Problemas con Pyro4 hoy:
- Está en modo mantenimiento desde ~2021 (última versión 4.82)
- Requiere monkeypatch de
msgpackpara soportar mensajes > 1 GB (serverProxy.pylíneas 1–35) - La reconexión en caída de red requiere wrappers custom (
PyroReconnectServerProxy,MultiThreadPyroReconnectServerProxy) - Las URIs se parsean con string splitting ad-hoc (
PYROLOC://server:8011) _invalidatepuede dispararse como llamada remota accidentalmente (ver PR #3775 / Sentry FIERRO-SERVER-2N)
HTTPServerProxy reimplementa la misma interfaz sobre requests. flaskServer.py recibe los requests y llama al mismo dispatcher.
Lo que funciona bien: cliente y server comparten el mismo dispatcher, la lógica de negocio es idéntica en ambos modos.
Los obstáculos específicos de HTTP:
| Problema | Cómo se manifiesta |
|---|---|
| Excepciones | Pyro las reconstruye automáticamente; HTTP las serializa como {"error":"ValidationError","description":"…"} y el cliente las reconstruye a mano (serverProxy.py:162) |
| Tipos especiales | Report y File tienen marshall explícito en pack() (flaskServer.py:57); agregar un tipo nuevo requiere tocar ese punto |
| Sesión | Pyro la pasa por contexto del proxy; HTTP la embebe en la URI (?sessionId=) o en header Authorization: SessionID 123 |
| Sin push | Pyro soporta callbacks cliente←server; HTTP no, entonces updates en tiempo real requieren polling |
| Payload grande | Mensajes de 50 MB se entregan de una vez; no hay streaming chunked |
El sistema asume un contrato que el código no cumple:
"Un objeto que viene del server es un snapshot del estado en ese instante. Una vez unmarshalled, no debería cambiar."
En la práctica los objetos son mutables y se modifican por tres razones:
- GrossRef lazy loading:
BookGrossRefcarga atributos on-demand llamando al manager. El mismo objeto Python puede tener distintos atributos en distintos momentos. - Write-through en caché: antes de PR #4166 (ticket 49032), al actualizar un objeto el código escribía el objeto editado directamente en el caché. Si el objeto editado estaba incompleto, el caché quedaba con datos parciales.
- Compartición de referencias: el GrossRef en caché y el que usa la UI son el mismo objeto. Una modificación en la pantalla de edición contamina el caché.
Hay cuatro capas de caché independientes que no coordinan entre sí:
block-beta
columns 1
block:CLIENT["🖥️ CLIENTE"]:1
A["CacheWrapper\n(initManagers.py)\nstrongDict: {pk → GrossRef} ← MUTABLE\nInvalidación total en mutaciones"]
B["lru_unmarshallWithRefs\n(objectTransferLib.py:481)\nEvita duplicar instancias del mismo GrossRef\nActualiza __dict__ de refs ya existentes"]
C["cache_per_request\n(fierrotools/cache.py)\nCache local por thread/request\nSe limpia entre pantallas"]
end
block:SERVER["🖧 SERVER"]:1
D["FullCacheDbObjectPool\n(server/model/utils/cache.py)\n__cache: {pk → objeto server} ← MUTABLE\nInvalidación total o por clave desde PR #4166"]
end
Cada capa fue agregada independientemente para resolver un problema de performance puntual, sin un modelo de coherencia global.
PR #3775 / Sentry FIERRO-SERVER-2N: PartyNotesEditionConfig llamaba manager._invalidate() de forma ciega. CacheReuseWrapper.__getattr__ captura cualquier nombre de método y lo delega al manager subyacente — incluyendo _invalidate. Para SalesmanManager, que no tiene _invalidate localmente, el proxy terminaba enviando una llamada remota al server con method="_invalidate". El server tiraba AttributeError.
# El problema en CacheReuseWrapper.__getattr__
def __getattr__(self, attName):
if attName in self.invalidateMethods:
self._invalidate()
return getattr(self.manager, attName) # ← si no existe localmente,
# llama al manager REMOTOPR #4166 (ticket 49032): al enviar documentos, la UI modificaba el objeto y hacía write-through al caché. El objeto modificado para el formulario (campos parciales) sobreescribía el objeto completo en caché. Fix: reemplazar write-through por drop_cached_item (invalidación puntual).
La causa raíz de ambos: los objetos del dominio son mutables, compartidos por referencia entre caché y UI, sin contrato explícito sobre quién puede modificarlos.
Los niveles están ordenados por esfuerzo/riesgo ascendente. Cada nivel es independiente del siguiente.
0.1 Proteger __getattr__ en los wrappers de caché
El fix de PR #3775 fue puntual. Generalizarlo en CacheReuseWrapper:
def __getattr__(self, attName):
if attName in self.invalidateMethods:
if hasattr(type(self.manager), attName): # solo si existe localmente
self._invalidate()
return getattr(self.manager, attName)0.2 Tests de regresión de caché
PR #4166 agregó tests/unitarios/test_cache_wrapper.py. Ampliar con casos que verifiquen que modificar un objeto devuelto del caché no afecta la entrada del caché.
0.3 Completar el saneamiento del código generado (PR #4062 / #4449)
PR #4062 ("Ticket 3887: aplicar Ruff a codigo generado") agregó ruff check --fix-only al final de makeMarshalling.sh. Quedó incompleto: E721 (type(value) == dict) no es auto-fixable por ruff, dejando 8.413 violations en los *Marshalling.py generados.
La raíz estaba en el generador generateMarshallings.py: emitía type(value) == dict/list en genUnmarshallReference y genUnmarshallByValue. PR #4449 lo corrige cambiando el generador a isinstance() — una línea en el generador → 0 E721 en todos los archivos generados. El objetivo es código generado limpio y compliance, no # noqa.
1.1 Unificar services.py cliente/servidor
Actualmente existen colofon/client/ipc/services.py y colofon/server/ipc/services.py con contenido casi idéntico — mismas estructuras Service/Method, distintas referencias a funciones de marshall. El generador los produce por separado porque el módulo de import difiere (colofon.client.ipc vs colofon.server.ipc).
Alternativa: generar un colofon/ipc/services_schema.py con la estructura del contrato (sin funciones de marshall) y extender en cliente/server:
# colofon/ipc/services_schema.py (generado — fuente de verdad del contrato)
SERVICE_SCHEMA = {
"/book": {
"findByPrimaryKey": {"params": [int], "return": "book.Book"},
"queryItems": {"params": [dict], "return": "list[book.Book]"},
},
...
}
# colofon/client/ipc/services.py (generado — usa client ipc marshalling)
from colofon.ipc.services_schema import SERVICE_SCHEMA
services = build_services(SERVICE_SCHEMA, ipc=colofon.client.ipc)
# colofon/server/ipc/services.py (generado — usa server ipc marshalling)
from colofon.ipc.services_schema import SERVICE_SCHEMA
services = build_services(SERVICE_SCHEMA, ipc=colofon.server.ipc)Esto no cambia el contrato en runtime, pero elimina la duplicación y hace auditable qué métodos existen desde un solo lugar.
1.2 Limpiar lazyClasses.py generado
lazyClasses.py genera cientos de pares ThinRef/GrossRef con makeRefClasses. Mientras se avanza hacia el nivel 3, agregar __slots__ en las clases generadas reduce el footprint de memoria de los GrossRef en caché.
2.1 Migrar de Pyro4 a HTTP como transporte principal
El modo HTTP ya existe y comparte el mismo dispatcher. Lo que falta para hacerlo equivalente:
- Excepciones: centralizar en un
ExceptionRegistry(ya hay base encolofon/model/exception.py) y usarlo simétricamente enpack()/unpack() - Sesión: unificar en header
Authorization(ya funciona); eliminar el hack de?sessionId=en la URI - Payload grande:
Transfer-Encoding: chunkedo multipart para reportes en lugar de un bloque único
Una vez equivalente, Pyro4 se puede deprecar sin tocar la lógica de negocio.
2.2 Objetos inmutables con Pydantic v2
El problema central de los bugs de caché es la mutabilidad. Los objetos del server deberían ser inmutables:
from pydantic import BaseModel, ConfigDict
class Book(BaseModel):
model_config = ConfigDict(frozen=True)
id: int
title: str | None = None
int_code: str | None = None
prices: dict[str, "Price"] = {}frozen=True hace que cualquier asignación tire ValidationError. Para edición, el cliente trabaja con una copia:
# En lugar de: book.title = "nuevo título"
book_editado = book.model_copy(update={"title": "nuevo título"})Con objetos inmutables:
- El caché puede guardar el objeto directamente sin riesgo de contaminación
lru_unmarshallWithRefsquedaría obsoleto (no tiene sentido actualizar un objeto cacheado)- Los tests se simplifican (sin efectos de borde por mutación compartida)
Dificultad: muchas partes del código cliente modifican los objetos directamente para los formularios. Hay que separar "objeto de lectura" (inmutable, del caché) de "objeto de edición" (mutable, local a la pantalla). Esto también es un beneficio de diseño.
2.3 Tipado completo del contrato IPC
Con el modelo basado en Pydantic, el IDE puede navegar y verificar tipos end-to-end. Se pueden generar stubs .pyi para que mypy verifique que el código cliente pasa el tipo correcto a cada método remoto:
# colofon/ipc/book_service.pyi (generado o escrito a mano una vez)
class BookService:
def find_by_primary_key(self, id: int) -> Book: ...
def query_items(self, filter: dict[str, str]) -> list[Book]: ...Esto elimina una clase entera de bugs donde se pasa el tipo equivocado y solo falla en runtime.
pydantic-rpc (MIT, activo al 2026-06-20, v0.15.2) es una librería que expone clases Python con métodos tipados como servicios ConnectRPC/gRPC sin escribir archivos protobuf. Genera el .proto en runtime a partir de las type annotations de Pydantic.
from pydantic_rpc import Message, Server
class BookId(Message):
id: int
class Book(Message):
id: int
title: str | None = None
class BookService:
def find_by_primary_key(self, request: BookId) -> Book:
... # lógica de negocio
Server(BookService()).run() # levanta servidor ConnectRPC/HTTPEso es todo del lado server. No hay XML, no hay generador de código, no hay makeMarshalling.sh.
El problema central de Fierro es que el contrato cliente/server vive en XML y requiere un pipeline de generación frágil. pydantic-rpc resuelve exactamente ese problema: las firmas Python tipadas son el contrato, y la serialización la maneja Pydantic automáticamente.
| Problema actual | Solución con pydantic-rpc |
|---|---|
| XML como fuente de verdad | Clases Python con type hints |
*Marshalling.py generados × 2 |
Pydantic serializa/deserializa automáticamente |
makeMarshalling.sh obligatorio |
No hay paso de generación |
| Duplicación cliente/server | Un paquete de modelos compartido |
| Pyro4 obsoleto | ConnectRPC (HTTP moderno, estándar abierto) |
| Excepciones custom | gRPC status codes estándar |
ConnectRPC no es gRPC puro — es un protocolo HTTP/JSON compatible con browsers y con herramientas estándar:
# Una llamada ConnectRPC desde curl (sin cliente especial)
curl -X POST https://server/BookService/FindByPrimaryKey \
-H "Content-Type: application/json" \
-d '{"id": 42}'
# → {"id": 42, "title": "El nombre del viento", ...}Esto significa que el cliente de Fierro (Python) puede ser tan simple como httpx + model_validate, sin generar stubs:
# colofon/client/ipc/book_client.py
import httpx
from colofon.ipc.models.book import Book, BookId
def find_by_primary_key(id: int, session_id: int) -> Book:
r = httpx.post(
f"{SERVER_URL}/BookService/FindByPrimaryKey",
json={"id": id},
headers={"connect-protocol-version": "1", "Authorization": f"SessionID {session_id}"},
)
return Book.model_validate(r.json())Paso 3.1 — Paquete de modelos compartido
Crear colofon/ipc/ como workspace member de uv (ya está configurado en pyproject.toml). Definir los modelos como pydantic_rpc.Message (que es BaseModel con frozen=True y algunas restricciones de serialización):
# colofon/ipc/models/book.py
from pydantic_rpc import Message
class BookId(Message):
id: int
class Book(Message):
id: int
title: str | None = None
int_code: str | None = None
# ... campos con tipos explícitos, mismos que en el XML actual
class BookFilter(Message):
filter: dict[str, str] = {}
class BookList(Message):
items: list[Book]Este paquete reemplaza las definiciones XML de modulesConfig.xml y los atributos en services.xml.
Paso 3.2 — Servicio server (un manager a la vez)
Crear un servicio que envuelve el manager existente sin tocar la lógica de DB:
# colofon/server/services/book_service.py
from colofon.ipc.models.book import Book, BookId, BookFilter, BookList
from colofon.server.model.catalogo.book import BookManagerPool
class BookService:
def find_by_primary_key(self, request: BookId) -> Book:
mgr = BookManagerPool().getManager()
b = mgr.findByPrimaryKey(request.id)
return Book.model_validate(b.__dict__)
def query_items(self, request: BookFilter) -> BookList:
mgr = BookManagerPool().getManager()
books = mgr.queryItems(request.filter)
return BookList(items=[Book.model_validate(b.__dict__) for b in books])La lógica de negocio en los managers no cambia. Solo el adaptador de entrada/salida.
Paso 3.3 — Servidor ConnectRPC montado en Flask existente
pydantic-rpc genera una app ASGI. Se puede montar junto al Flask actual durante la transición:
# colofon/server/flaskServer.py (existente, sin romper)
from pydantic_rpc import ConnectRPCApp
from colofon.server.services.book_service import BookService
connectrpc_app = ConnectRPCApp(BookService())
# montar en /rpc/ → los endpoints viejos siguen en /run/Esto permite migrar servicio por servicio: el cliente puede llamar /run/book/findByPrimaryKey/ (viejo) o /rpc/BookService/FindByPrimaryKey (nuevo) en paralelo.
Paso 3.4 — Cliente httpx por servicio
Por cada servicio migrado, reemplazar el manager cliente (actualmente un wrapper sobre el proxy Pyro/HTTP) por un cliente directo:
# colofon/client/ipc/book_client.py
from colofon.ipc.models.book import Book, BookId, BookFilter, BookList
class BookClient:
def findByPrimaryKey(self, id: int) -> Book: # misma interfaz que el manager actual
r = _http.post("/rpc/BookService/FindByPrimaryKey", json={"id": id})
return Book.model_validate(r.json())El resto del código cliente (colofon/client/model/, colofon/client/ui/) recibe un objeto Book de Pydantic donde antes recibía un BookGrossRef. La interfaz es compatible si los atributos coinciden.
Paso 3.5 — Eliminar el generador
Una vez todos los servicios migrados:
- Borrar
fierrotools/objectTransfer/ - Borrar
colofon/makeMarshalling.sh - Borrar
colofon/*/ipc/*Marshalling.pydel.gitignore(ya no existen) - Borrar
colofon/*/ipc/services.pygenerados
| ✅ Lo que pydantic-rpc resuelve | |
|---|---|
| Elimina el generador XML | Mapeo GrossRef → Book frozen (ver 2.2) |
| Un contrato tipado y navegable | Sesión: pasar sessionId como header en cada request |
| Serialización automática con Pydantic v2 | Autenticación: interceptor gRPC o middleware Flask |
| Elimina Pyro4 | Excepciones: mapear ValidationError a gRPC status |
| ConnectRPC es HTTP estándar (curl, browser) | Streaming de reportes: usar AsyncIterator[T] |
| Migración incremental servicio a servicio | Caché cliente: los objetos frozen son cacheables sin riesgo |
- Uniones con colecciones:
Union[list[Book], None]no funciona directamente (restricción protobuf); usarBookList | Nonecon wrapper - 77 estrellas: librería pequeña, puede tener rough edges en casos de borde
- El
.protose genera en runtime: no se puede versionar ni distribuir directamente (es una ventaja para desarrollo, potencial limitación para interoperabilidad futura) - Campos nuevos: los serializadores de Pydantic que agregan campos nuevos son ignorados por protobuf
Una alternativa más simple es FastAPI + httpx sin pydantic-rpc:
| pydantic-rpc + ConnectRPC | FastAPI + httpx | |
|---|---|---|
| Definición del contrato | Clase Python tipada (un lugar) | Rutas FastAPI + modelos Pydantic (dos lugares) |
| Cliente | httpx directo o stub generado | httpx directo |
| Streaming | AsyncIterator[T] nativo |
SSE o chunked manual |
| Interoperabilidad | gRPC/ConnectRPC estándar (multi-lenguaje) | REST/OpenAPI (más universal) |
| Complejidad | Baja (un decorator, un Server()) |
Muy baja (decoradores FastAPI familiares) |
| Dependencias nuevas | pydantic-rpc, grpcio | solo fastapi (ya hay Flask) |
Recomendación: si el objetivo es principalmente eliminar el generador y simplificar el stack Python-a-Python, FastAPI + httpx es más simple y más familiar. pydantic-rpc agrega valor si en el futuro se quieren clients en otros lenguajes o integración MCP con agentes de IA (lo cual pydantic-rpc soporta nativamente). Son dos caminos válidos; pydantic-rpc es el más ambicioso y el que más elimina de golpe.
| Qué querés resolver | Nivel |
|---|---|
| Bugs de caché inmediatos | 0.1, 0.2 |
| Reducir ruido en Sentry | 0.1 |
| Código generado ruff-compliant | 0.3 → PR #4449 |
| Reducir duplicación cliente/server | 1.1 |
| Eliminar Pyro4 | 2.1 |
| Eliminar bugs de mutabilidad | 2.2 |
| Tipado completo end-to-end | 2.3 |
| Eliminar el generador XML y Pyro4 de raíz | 3 (pydantic-rpc o FastAPI+httpx) |
| Interoperabilidad multi-lenguaje / MCP | 3 con pydantic-rpc |
| Migración incremental servicio a servicio | 3 (pasos 3.1–3.5) |
XML (services.xml, modulesConfig.xml)
↓ inv make-marshalling (bash → Python en container Docker)
*Marshalling.py × 2 (client + server, gitignored)
services.py × 2 (client + server)
lazyClasses.py × 2 (client + server)
↓ runtime
Cliente llama BookManager.findByPrimaryKey(42)
→ marshallBookThinRef(42) = [42]
→ Pyro4/HTTP → server
→ manager.findByPrimaryKey(42) → SELECT … → Book(…)
→ marshallBookGrossRef(book) = {"id":42, "title":"…", …}
→ cliente
→ unmarshallBookGrossRef({…}) = BookGrossRef(id=42, title="…")
→ UI lo modifica → ¿caché contaminado?
colofon/ipc/models/book.py (pydantic_rpc.Message, frozen por defecto)
↓ sin generación — la clase ES el contrato
BookService en server envuelve el manager existente
↓ runtime
Cliente llama BookClient.findByPrimaryKey(42)
→ httpx POST /rpc/BookService/FindByPrimaryKey {"id": 42}
→ pydantic-rpc deserializa BookId(id=42)
→ BookService.find_by_primary_key(BookId(id=42))
→ manager.findByPrimaryKey(42) → Book del dominio
→ Book.model_validate(b.__dict__) → Book(id=42, title="…")
→ pydantic-rpc serializa → JSON
→ cliente
→ Book.model_validate(r.json()) → Book(id=42, title="…", frozen)
→ UI trabaja con copia: book.model_copy(update={"title": "nuevo"})
→ caché almacena Book (frozen → contaminación imposible)
| Concepto | Archivo |
|---|---|
| Generador de marshalling | fierrotools/objectTransfer/generateMarshallings.py |
| Runtime de marshalling | fierrotools/objectTransferLib.py |
| Wrapper de marshalling | colofon/util/marshallWrapper.py |
| Proxy Pyro / HTTP | colofon/client/ipc/serverProxy.py |
| Caché cliente (fuerte) | colofon/client/ipc/initManagers.py — CacheWrapper |
| Caché server (full) | colofon/server/model/utils/cache.py — FullCacheDbObjectPool |
| Caché por request | fierrotools/cache.py |
| Caché de unmarshalling | fierrotools/objectTransferLib.py — lru_unmarshallWithRefs |
| Lazy classes generadas | colofon/client/ipc/lazyClasses.py (generado) |
| Contrato XML | colofon/server/services.xml, colofon/client/ipc/modulesConfig.xml |
Bug _invalidate remoto |
PR #3775 / Sentry FIERRO-SERVER-2N |
| Bug write-through caché | PR #4166 (ticket 49032) |
| Fix código generado ruff | PR #4062 (incompleto) → PR #4449 |