Skip to content

Commit 0e50495

Browse files
committed
fix: Warn + preserve unknown tags
1 parent 219d413 commit 0e50495

3 files changed

Lines changed: 177 additions & 11 deletions

File tree

src/ASDF.jl

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,65 @@ function correct_byteorder(data, ::Ucs4Datatype, byteorder::Byteorder)
883883
return data
884884
end
885885

886+
"""
887+
TaggedMapping{D} <: AbstractDict
888+
TaggedSequence{V} <: AbstractVector
889+
TaggedScalar <: AbstractString
890+
891+
A YAML node that carries an unrecognized (extension) `tag`, produced by [`ASDF.load_file`](@ref)
892+
when called with `extensions = true`. Each behaves exactly like its wrapped `value` — a mapping
893+
indexes and iterates as a dict, a sequence as a vector, a scalar as a string — while retaining
894+
the original `tag` so the node round-trips unchanged through [`ASDF.write_file`](@ref).
895+
"""
896+
struct TaggedMapping{D<:AbstractDict} <: AbstractDict{Any, Any}
897+
tag::String
898+
value::D
899+
end
900+
# `keys`, `values`, `haskey`, `get`, etc. fall back to these via the `AbstractDict` interface.
901+
Base.getindex(m::TaggedMapping, k) = getindex(m.value, k)
902+
Base.length(m::TaggedMapping) = length(m.value)
903+
Base.iterate(m::TaggedMapping, state...) = iterate(m.value, state...)
904+
905+
@doc (@doc TaggedMapping)
906+
struct TaggedSequence{V<:AbstractVector} <: AbstractVector{Any}
907+
tag::String
908+
value::V
909+
end
910+
Base.size(s::TaggedSequence) = size(s.value)
911+
Base.getindex(s::TaggedSequence, i::Int) = getindex(s.value, i)
912+
Base.IndexStyle(::Type{<:TaggedSequence}) = IndexLinear()
913+
914+
@doc (@doc TaggedMapping)
915+
struct TaggedScalar <: AbstractString
916+
tag::String
917+
value::String
918+
end
919+
Base.ncodeunits(s::TaggedScalar) = ncodeunits(s.value)
920+
Base.codeunit(s::TaggedScalar) = codeunit(s.value)
921+
Base.codeunit(s::TaggedScalar, i::Integer) = codeunit(s.value, i)
922+
Base.isvalid(s::TaggedScalar, i::Integer) = isvalid(s.value, i)
923+
Base.iterate(s::TaggedScalar, i::Integer = 1) = iterate(s.value, i)
924+
925+
# Serialize a tagged node by emitting its verbatim tag (`!<...>`) right before the wrapped
926+
# value, mirroring how `NDArrayWrapper` writes `!core/ndarray-1.0.0`. The YAML writer breaks the
927+
# line after the key only for a *non-empty* block collection, so the tag goes on its own
928+
# indented line there; for scalars and for empty collections (printed inline as `{}`/`[]`) the
929+
# writer leaves us on the key's line, so the tag stays inline. Both forms parse back to a tagged
930+
# node.
931+
function YAML._print(io::IO, val::Union{TaggedMapping, TaggedSequence}, level::Int = 0, ignore_level::Bool = false)
932+
if isempty(val.value)
933+
print(io, "!<", val.tag, "> ")
934+
YAML._print(io, val.value, level, ignore_level)
935+
else
936+
print(io, YAML._indent("!<" * val.tag * ">\n", level, ignore_level))
937+
YAML._print(io, val.value, level)
938+
end
939+
end
940+
function YAML._print(io::IO, val::TaggedScalar, level::Int = 0, ignore_level::Bool = false)
941+
print(io, "!<", val.tag, "> ")
942+
YAML._print(io, val.value, level, ignore_level)
943+
end
944+
886945
function YAML._print(io::IO, val::NDArray, level::Int = 0, ignore_level::Bool = false)
887946
# TODO: Get compression from underlying header block?
888947
YAML._print(io, NDArrayWrapper(val[]; compression = C_None), level, ignore_level)
@@ -1164,7 +1223,7 @@ Reads an ASDF file from disk.
11641223
| Parameter | Description |
11651224
| :------------------ | :--------------------------------------------------------------------------------------------------------------- |
11661225
| `filename` | Path to the `.asdf` file |
1167-
| `extensions` | When `true`, unknown YAML tags are parsed leniently (as maps, sequences, or scalars) instead of raising an error |
1226+
| `extensions` | When `true`, unknown YAML tags are parsed leniently (as maps, sequences, or scalars) instead of raising an error, and a warning naming each unrecognized tag is emitted (once per distinct tag). The tag is retained (see [`ASDF.TaggedMapping`](@ref)) so the node round-trips unchanged on write. |
11681227
| `validate_checksum` | When `true`, each block's MD5 checksum is verified against the stored value |
11691228
11701229
Block data is located lazily. Block headers are scanned after the YAML is parsed, and array data (`ndarray`) is read only when [`Base.getindex(ndarray::NDArray)`](@ref) is called, i.e., `ndarray[]`.
@@ -1181,14 +1240,21 @@ function load_file(filename::AbstractString; extensions = false, validate_checks
11811240
asdf_constructors["tag:stsci.edu:asdf/core/extension_metadata-1.0.0"] = ordered_map_constructor
11821241

11831242
if extensions
1184-
# Use fallbacks for now
1243+
# Use fallbacks for now. Track which unrecognized tags we have already
1244+
# warned about so each distinct tag is reported at most once per load.
1245+
warned_tags = Set{Any}()
11851246
asdf_constructors[nothing] = (constructor, node) -> begin
1247+
if node.tag warned_tags
1248+
push!(warned_tags, node.tag)
1249+
@warn "Unrecognized tag encountered while loading; falling back to a generic representation" tag = node.tag
1250+
end
1251+
# Wrap in a `Tagged*` type so the unrecognized tag is retained and round-trips on write.
11861252
if node isa YAML.MappingNode
1187-
return YAML.construct_mapping(constructor, node)
1253+
return TaggedMapping(node.tag, YAML.construct_mapping(OrderedDict{Any, Any}, constructor, node))
11881254
elseif node isa YAML.SequenceNode
1189-
return YAML.construct_sequence(constructor, node)
1255+
return TaggedSequence(node.tag, YAML.construct_sequence(constructor, node))
11901256
else
1191-
return YAML.construct_scalar(constructor, node)
1257+
return TaggedScalar(node.tag, YAML.construct_scalar(constructor, node))
11921258
end
11931259
end
11941260
end
@@ -1444,7 +1510,11 @@ function write_file(filename::AbstractString, document::AbstractDict)
14441510
# - [ ] maybe make the document not a `Dict` but the stuff with the `metadata` that the writer returns?
14451511
# - [ ] preserve insertion order? https://github.com/JuliaAstro/ASDF.jl/tree/ordered
14461512
library = ASDFLibrary(software_name, software_author, software_homepage, software_version)
1447-
full_document = merge(document, OrderedDict{Any, Any}("asdf_library" => library))
1513+
# Build the output tree as an `OrderedDict` regardless of the input dict's concrete type, so
1514+
# insertion order is preserved (for a `TaggedMapping` document, `merge` would otherwise fall
1515+
# back to an unordered `Dict` and drop the order). The provenance entry is stamped last.
1516+
full_document = OrderedDict{Any, Any}(document)
1517+
full_document["asdf_library"] = library
14481518

14491519
# Write YAML part of file
14501520
io = open(filename, "w")

test/test-read_fallbacks.jl

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,6 @@ end
3333

3434
# The default `extensions = false` case
3535
@test_throws Exception load_tag(tag_unknown_mapping; extensions = false)
36-
@test_throws Exception load_tag(tag_unknown_sequence; extensions = false)
37-
@test_throws Exception load_tag(tag_unknown_scalar; extensions = false)
3836

3937
# Should fall back to an `AbstractDict`
4038
af = load_tag(tag_unknown_mapping; extensions = true)
@@ -46,10 +44,12 @@ end
4644
# Known key still parsed as normal
4745
@test af.metadata["known_key"] == "hello"
4846

49-
# And loading the file multiple times does not mutate any shared/global state
47+
# Two loads must not cross-contaminate via shared/global state: load a second file with a
48+
# different value *after* the first, then check the first still reads its own value.
5049
af1 = load_tag(tag_unknown_mapping; extensions = true)
51-
af2 = load_tag(tag_unknown_mapping; extensions = true)
52-
@test af1.metadata["custom_obj"]["width"] == af2.metadata["custom_obj"]["width"]
50+
af2 = load_tag(replace(tag_unknown_mapping, "width: 42" => "width: 99"); extensions = true)
51+
@test af1.metadata["custom_obj"]["width"] == 42
52+
@test af2.metadata["custom_obj"]["width"] == 99
5353
end
5454

5555
@testset "unknown sequence" begin
@@ -61,6 +61,9 @@ end
6161
- gamma
6262
"""
6363

64+
# The default `extensions = false` case
65+
@test_throws Exception load_tag(tag_unknown_sequence; extensions = false)
66+
6467
# Should fall back to an `AbstractVector`
6568
af = load_tag(tag_unknown_sequence; extensions = true)
6669
list = af.metadata["custom_list"]
@@ -79,6 +82,9 @@ end
7982
custom_value: !<tag:example.org:mylib/quantity-1.0.0> 3.14
8083
"""
8184

85+
# The default `extensions = false` case
86+
@test_throws Exception load_tag(tag_unknown_scalar; extensions = false)
87+
8288
# Should fall back to an `AbstractString`
8389
af = load_tag(tag_unknown_scalar; extensions = true)
8490
value = af.metadata["custom_value"]
@@ -118,3 +124,73 @@ end
118124
# Known key unaffected
119125
@test md["known_key"] == "hello"
120126
end
127+
128+
@testset "unrecognized tag warning" begin
129+
tag_unknown_scalar = """
130+
custom_value: !<tag:example.org:mylib/quantity-1.0.0> 3.14
131+
"""
132+
133+
# A warning naming the offending tag should be emitted on load.
134+
@test_logs (:warn, r"Unrecognized tag") match_mode = :any begin
135+
load_tag(tag_unknown_scalar; extensions = true)
136+
end
137+
138+
# The same tag repeated should warn only once per load. Count the matching warnings
139+
# directly rather than via a strict `@test_logs`, so unrelated records do not interfere.
140+
# On Windows, `load_tag`'s retained file handle makes `mktempdir` emit a "cleanup" error.
141+
tag_repeated = """
142+
a: !<tag:example.org:mylib/quantity-1.0.0> 1
143+
b: !<tag:example.org:mylib/quantity-1.0.0> 2
144+
"""
145+
logs, _ = Test.collect_test_logs() do
146+
load_tag(tag_repeated; extensions = true)
147+
end
148+
@test count(r -> occursin("Unrecognized tag", string(r.message)), logs) == 1
149+
end
150+
151+
@testset "unknown tag roundtrip" begin
152+
# Loading with `extensions = true`, writing back out, and reloading must preserve both the
153+
# unrecognized tag and the value, for mappings, sequences, and scalars alike.
154+
tag_unknown_all = """
155+
mapping_node: !<tag:example.org:mylib/widget-1.0.0>
156+
width: 42
157+
height: 7
158+
sequence_node: !<tag:example.org:mylib/series-1.0.0>
159+
- alpha
160+
- beta
161+
scalar_node: !<tag:example.org:mylib/quantity-1.0.0> 3.14
162+
empty_mapping: !<tag:example.org:mylib/widget-1.0.0> {}
163+
empty_sequence: !<tag:example.org:mylib/series-1.0.0> []
164+
"""
165+
166+
af = load_tag(tag_unknown_all; extensions = true)
167+
af2 = mktempdir() do dir
168+
path = joinpath(dir, "roundtrip.asdf")
169+
ASDF.write_file(path, af.metadata)
170+
ASDF.load_file(path; extensions = true)
171+
end
172+
md = af2.metadata
173+
174+
@test md["mapping_node"] isa ASDF.TaggedMapping
175+
@test md["mapping_node"].tag == "tag:example.org:mylib/widget-1.0.0"
176+
@test md["mapping_node"]["width"] == 42
177+
@test md["mapping_node"]["height"] == 7
178+
179+
@test md["sequence_node"] isa ASDF.TaggedSequence
180+
@test md["sequence_node"].tag == "tag:example.org:mylib/series-1.0.0"
181+
@test md["sequence_node"][1] == "alpha"
182+
@test md["sequence_node"][2] == "beta"
183+
184+
@test md["scalar_node"] isa ASDF.TaggedScalar
185+
@test md["scalar_node"].tag == "tag:example.org:mylib/quantity-1.0.0"
186+
@test md["scalar_node"] == "3.14"
187+
188+
# Empty tagged collections must survive too (they serialize inline as `{}` / `[]`).
189+
@test md["empty_mapping"] isa ASDF.TaggedMapping
190+
@test md["empty_mapping"].tag == "tag:example.org:mylib/widget-1.0.0"
191+
@test isempty(md["empty_mapping"])
192+
193+
@test md["empty_sequence"] isa ASDF.TaggedSequence
194+
@test md["empty_sequence"].tag == "tag:example.org:mylib/series-1.0.0"
195+
@test isempty(md["empty_sequence"])
196+
end

test/test-write.jl

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,23 @@ end
5858
@test ASDF.native2big_U8(0x05) == [0x05]
5959
@test ASDF.native2big_U8(5) == [0x05]
6060
end
61+
62+
@testset "write preserves key order for non-OrderedDict documents" begin
63+
dir = mktempdir(; cleanup = true)
64+
path = joinpath(dir, "order.asdf")
65+
66+
# A `TaggedMapping` top-level document is an `AbstractDict` that is *not* an `OrderedDict`.
67+
# The old `merge(document, ...)` degraded such inputs to an unordered `Dict`, scrambling key
68+
# order; `write_file` now rebuilds an `OrderedDict` so insertion order is preserved. Use a
69+
# deliberately non-alphabetical order so hash ordering would not match by coincidence.
70+
doc = ASDF.TaggedMapping(
71+
"tag:example.org:mylib/root-1.0.0",
72+
ASDF.OrderedDict{Any, Any}("zebra" => 1, "apple" => 2, "mango" => 3),
73+
)
74+
75+
ASDF.write_file(path, doc)
76+
reloaded = ASDF.load_file(path)
77+
78+
# User keys keep their insertion order; the auto-inserted provenance entry comes last.
79+
@test collect(keys(reloaded.metadata)) == ["zebra", "apple", "mango", "asdf_library"]
80+
end

0 commit comments

Comments
 (0)