feat(v9): opt-in lenient decoding for str-typed slots - #1022
Conversation
The v9 decoder is stricter than the Atlas server it models. Atlas's
`string` type accepts any value and stores its stringification
(AtlasBuiltInTypes.AtlasStringType: isValidValue returns true,
getNormalizedValue returns obj.toString()). The generated models mirror
the declared Atlas type faithfully -- `map<string,string>` becomes
Dict[str, str] -- but cannot mirror that leniency, because msgspec
enforces str exactly. So a producer emitting {"ordinal_position": 1} in
customAttributes has its whole record rejected here, while Atlas accepts
the same record and stores "1".
There was no seam for this: strict=False coerces str -> int, never
int -> str, and a dec_hook fires only for types msgspec cannot natively
handle, so the mismatch raises before any hook runs.
Add `lenient_strings` to from_atlas_format and from_atlas_json. Off by
default, so decoding an Atlas API response is unchanged; callers decoding
payloads that have not been through Atlas's normalisation (connector
output, fixtures, a local validator) can opt in.
The coercion plan is derived from the model via msgspec.inspect, so it
covers every str slot -- plain str, Dict[str, str], List[Dict[str, str]],
List[str], and the same shapes on nested Related* structs -- with no
hand-maintained field-name list to fall behind. Plans are cached per
class, published before the class is walked so the cyclic Related* type
graph terminates.
Scalars become their compact JSON text, matching getNormalizedValue
(1 -> "1", true -> "true"). Containers in a string slot also become
compact JSON text -- Atlas would store Java's Object.toString(); JSON is
the honest serialisation of a JSON value and it round trips. None is
dropped from slots that cannot hold it and preserved where the model
declares Optional. Non-str slots stay strict, so genuine drift stays
visible.
The logic lives in a new hand-maintained module rather than in
transform.py, which is stamped auto-generated, so a future regeneration
costs the two-line wiring and not the implementation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks for the detailed writeup @cmgrote — the A few thoughts, mostly agreeing with your own draft notes: On v11 / the motivating case: I don't think we need this to land the Prefer widening over coercing where the field is genuinely free-form. #2041's approach keeps the value; coercion mutates The wiring lands in a generated file. Validator semantics are the crux — and you flag it yourself. Silent coercion is correct for "scalar in a string slot" (Atlas accepts it, so a validator shouldn't flag it). But for a genuine violation (nested object in a declared Minor: containers stringify to compact JSON ( Net: the two PRs aren't competing. #1024 = the root fix (via #2041) for the case that's actually hurting, in time for v11. #1022 = a broader validator-leniency feature worth maturing separately — with the wiring moved out of generated code and the reporting surface added. Happy to help on either. |
Important
Draft — do not merge as-is. The root fix for the motivating case is
atlanhq/models#2041, which retypesEntity.custom_attributestoUnion[Dict[str, Any], None, UnsetType]attoolkit/src/main/pkl/PythonMsgspecRenderer.pkl:2244. Measured againstapplication-sdk's canonical SQL transformed fixtures, that one-line rendererchange alone takes decoding from 1/105 to 105/105 records — the same result
this PR achieves, but by preserving values (
1stays1, round-trippingback out unchanged) instead of stringifying them, and with no hand-written
change to this repo beyond the regen.
This PR stays open because leniency still covers one case
#2041does not: atypedef attribute whose declared
map<string,string>the producer violates —e.g.
TableauDatasourceField.upstreamColumnscarrying nested objects. Butsilently coercing that suppresses exactly the finding a validator exists to
surface. Before merge it should coerce and report — surfacing the paths it
coerced — so a caller can count them as findings instead of having them vanish.
feat(v9): opt-in lenient decoding for
str-typed slotsProblem
The v9 decoder is stricter than the Atlas server it models.
Atlas's
stringtype accepts any value and stores its stringification —AtlasBuiltInTypes.AtlasStringTypeinatlas-metastore:The generated models mirror the declared Atlas type faithfully — a typedef
attribute of
array<map<string,string>>becomesList[Dict[str, str]], andAtlasEntity.customAttributes(Map<String, String>) becomesDict[str, str]— but they cannot mirror that leniency, because msgspecenforces
strexactly.So a producer that emits
{"ordinal_position": 1}incustomAttributeshasits entire record rejected here, while Atlas accepts the same record and
stores
"1".There is no existing seam for this:
msgspec.convert(..., strict=False)(already passed)str→int, neverint→str.dec_hook=str/intmismatch raises before any hook runs.Dict[str, Any]atlanhq/models(PythonMsgspecRenderer.pkl:255for the typedef mapping,:2244for the hardcodedEntity.custom_attributesline).Blast radius of the shape: the Atlas model set carries 45
map<string,string>and 40
array<map<string,string>>attributes, which land as 64 distinctDict[str, str]field names acrosspyatlan_v9/model/assets/.Fix
from_atlas_format(data, *, lenient_strings=False)and the same keyword onfrom_atlas_json. When enabled, values bound for astr-typed slot arecoerced the way Atlas's
stringtype would coerce them.Default is off, so decoding an Atlas API response is byte-for-byte
unchanged. Turn it on when decoding a payload that has not been through
Atlas's normalisation — connector output, fixtures, a local validator — where
an
intin aDict[str, str]is a producer quirk Atlas would have stringifiedrather than a contract breach.
The coercion plan is derived from the model itself via
msgspec.inspect, so itcovers every
strslot — plainstr,Dict[str, str],List[Dict[str, str]],List[str], and the same shapes on nestedRelated*structs — with no hand-maintained list of field names to fall behind. A new
map<string,string>typedef attribute is covered the moment the models areregenerated. Plans are cached per class; the cache entry is published before
the class is walked, so the cyclic
Related*type graph terminates.Policy, and the reasoning for each choice:
getNormalizedValueexactly:1→"1",true→"true"(JSON, notPython's
"True").store Java's
Object.toString()({a=b}); this stores{"a":"b"}. Adocumented divergence — JSON is the honest serialisation of a JSON value and
it round trips.
Noneis dropped from a slot that cannot hold it (Dict[str, str]values, and non-optional fields) and preserved where the model declares
Optional. Dropping rather than stringifying is deliberate: a literal"null"is a value the producer never wrote.strslots stay strict. A wrong-typedintor nested objectelsewhere still raises, so genuine drift stays visible.
Logic lives in a new hand-maintained module,
pyatlan_v9/model/string_coercion.py,rather than inside
transform.py—transform.pyis stampedAuto-generated by PythonMsgspecRenderer.pkl - DO NOT EDIT, so keeping thelogic out of it means a future regeneration costs the two-line wiring, not the
implementation.
Tests
tests_v9/unit/test_string_coercion.py— 29 tests:customAttributesshape: already-str, int, float, bool, null value,mixed, empty, nested object, nested array, explicit null map, absent,
non-mapping.
upstreamColumnsshape, including the nested-object case and a raggedlist.
strslots: scalar coerced;Nonepreserved where the field isOptional.RelatedTableauDatasourceField.upstream_columnsreached through arelationship attribute — the shape a name-keyed fixup would miss.
strslots still raise with the flag on; malformed JSON still raises.upstreamColumns,upstreamFields,customAttributes,qualifiedNameare all covered for one type) and cachedper class.
7219 passed, 5 skippedacrosstests_v9/unit.ruff format --checkandruff checkclean.mypyclean on the new module (transform.pykeeps its twopre-existing
register_asseterrors, untouched by this change).Performance
50k decodes of a realistic mysql column (24
customAttributes, most non-str):lenient_strings=False(default path)lenient_strings=True, clean payloadlenient_strings=True, dirty payloadThe default path is unaffected — the only added work is one
if. Callers thatopt in pay ~26%.
Follow-ups (not in this PR)
atlanhq/models—PythonMsgspecRenderer.pkl:2244hardcodescustom_attributes: Union[Dict[str, str], UnsetType]on theEntitybasetemplate, while the same renderer emits
Anyfor the*Nestedvariant(
referenceable.py:303). Worth making consistent regardless of this PR.application-sdkcan drop its private key-registry walk inapplication_sdk/validation/assets.pyand passlenient_strings=Trueinstead — no inlined copy of
from_atlas_jsoninternals, and coverage ofall 64 string-map fields rather than 2.