claricle 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +10 -1
- data/LICENSE +25 -0
- data/README.adoc +439 -157
- data/TODO.plan/00-overview.md +502 -0
- data/TODO.plan/01-core.md +296 -0
- data/TODO.plan/02-inspect.md +163 -0
- data/TODO.plan/03-conform.md +208 -0
- data/TODO.plan/04-convert.md +216 -0
- data/exe/claricle +1 -1
- data/lib/claricle/batch.rb +240 -0
- data/lib/claricle/cli.rb +514 -40
- data/lib/claricle/conformance_options.rb +49 -0
- data/lib/claricle/conversion_engine.rb +21 -0
- data/lib/claricle/convert.rb +21 -0
- data/lib/claricle/detector.rb +696 -0
- data/lib/claricle/emf_header.rb +33 -0
- data/lib/claricle/errors.rb +73 -0
- data/lib/claricle/fault.rb +69 -0
- data/lib/claricle/handlers/base.rb +169 -0
- data/lib/claricle/handlers/extension_points.rb +124 -0
- data/lib/claricle/handlers/metafile.rb +1011 -0
- data/lib/claricle/handlers/pdf.rb +961 -0
- data/lib/claricle/handlers/pdf_arlington/definitions.rb +54 -0
- data/lib/claricle/handlers/pdf_arlington/kind.rb +62 -0
- data/lib/claricle/handlers/pdf_arlington/predicate_gate.rb +39 -0
- data/lib/claricle/handlers/pdf_arlington/rules.rb +86 -0
- data/lib/claricle/handlers/pdf_arlington/walker.rb +131 -0
- data/lib/claricle/handlers/pdf_arlington.rb +38 -0
- data/lib/claricle/handlers/pdf_profiles.rb +66 -0
- data/lib/claricle/handlers/png.rb +1107 -0
- data/lib/claricle/handlers/postscript.rb +1357 -0
- data/lib/claricle/handlers/svg.rb +787 -0
- data/lib/claricle/image.rb +272 -0
- data/lib/claricle/lossiness/levels.rb +13 -0
- data/lib/claricle/lossiness.rb +864 -0
- data/lib/claricle/models/base.rb +460 -0
- data/lib/claricle/models/batch_item.rb +127 -0
- data/lib/claricle/models/conversion.rb +167 -0
- data/lib/claricle/models/format_capability.rb +40 -0
- data/lib/claricle/models/free_form_hash.rb +77 -0
- data/lib/claricle/models/inspection.rb +188 -0
- data/lib/claricle/models/issue.rb +23 -0
- data/lib/claricle/models/location.rb +51 -0
- data/lib/claricle/models/report.rb +54 -0
- data/lib/claricle/models/uncoerced_integer.rb +38 -0
- data/lib/claricle/postscript_fidelity/verdict.rb +37 -0
- data/lib/claricle/postscript_fidelity/view_box.rb +82 -0
- data/lib/claricle/postscript_fidelity.rb +160 -0
- data/lib/claricle/registry.rb +125 -0
- data/lib/claricle/version.rb +1 -1
- data/lib/claricle/writer.rb +468 -0
- data/lib/claricle.rb +244 -7
- metadata +201 -19
- data/claricle.gemspec +0 -42
- data/sig/claricle.rbs +0 -4
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "lutaml/model"
|
|
5
|
+
|
|
6
|
+
module Claricle
|
|
7
|
+
module Models
|
|
8
|
+
# The per-attribute checks `validate_deeply` runs beyond what lutaml
|
|
9
|
+
# validates on its own. Its own module because Base crosses RuboCop's
|
|
10
|
+
# class-length limit with this much of it inline -- measured, not
|
|
11
|
+
# assumed: folded back in, RuboCop reports Base at 111 lines against
|
|
12
|
+
# a limit of 100. Dropping the Marshal protocol did not change that.
|
|
13
|
+
module Validation
|
|
14
|
+
CORE_INSTANCE = ::Object.instance_method(:instance_of?)
|
|
15
|
+
KIND_OF = ::Object.instance_method(:is_a?)
|
|
16
|
+
CLASS_OF = ::Object.instance_method(:class)
|
|
17
|
+
FROZEN = ::Object.instance_method(:frozen?)
|
|
18
|
+
ARRAY_ELEMENTS = ::Array.instance_method(:each)
|
|
19
|
+
ARRAY_FIRST = ::Array.instance_method(:first)
|
|
20
|
+
ARRAY_SIZE = ::Array.instance_method(:size)
|
|
21
|
+
private_constant :CORE_INSTANCE, :KIND_OF, :CLASS_OF, :FROZEN,
|
|
22
|
+
:ARRAY_ELEMENTS, :ARRAY_FIRST, :ARRAY_SIZE
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def core_instance?(value, type)
|
|
27
|
+
CORE_INSTANCE.bind_call(value, type)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def inherits_from?(value, type)
|
|
31
|
+
KIND_OF.bind_call(value, type)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def class_of(value)
|
|
35
|
+
CLASS_OF.bind_call(value)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def object_frozen?(value)
|
|
39
|
+
FROZEN.bind_call(value)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def each_array(value, &)
|
|
43
|
+
ARRAY_ELEMENTS.bind_call(value, &)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def array_first(value)
|
|
47
|
+
ARRAY_FIRST.bind_call(value)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def array_size(value)
|
|
51
|
+
ARRAY_SIZE.bind_call(value)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# One sentence for every one of these checks, and one place that
|
|
55
|
+
# knows lutaml's ValidationError carries exceptions rather than
|
|
56
|
+
# Strings -- handed bare Strings, its own `error_messages` blows
|
|
57
|
+
# up. Subclasses raise through here too.
|
|
58
|
+
def refuse(name, expectation, got)
|
|
59
|
+
raise Lutaml::Model::ValidationError,
|
|
60
|
+
[TypeError.new("#{name} expects #{expectation}, got #{got}")]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A declared model attribute is only cast when it arrives as a hash
|
|
64
|
+
# from a document. Handed a wrong-typed object directly, lutaml stores
|
|
65
|
+
# it as-is and the failure surfaces much later as a NoMethodError.
|
|
66
|
+
def validate_types
|
|
67
|
+
self.class.attributes.each do |name, attribute|
|
|
68
|
+
validate_finite(name)
|
|
69
|
+
validate_default_bookkeeping(name, attribute)
|
|
70
|
+
|
|
71
|
+
type = attribute.type
|
|
72
|
+
next unless type.is_a?(Class) && type < Base
|
|
73
|
+
|
|
74
|
+
validate_attribute(name, attribute, type)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# `using_default_for` is public on lutaml, for its own deserializer
|
|
79
|
+
# to mark an attribute absent from the document. A caller can reach
|
|
80
|
+
# it too -- through the block form of `new`, before this runs -- and
|
|
81
|
+
# flip ANY attribute to "using its default" while it still holds
|
|
82
|
+
# the value it was actually given. Rendering skips whatever is
|
|
83
|
+
# marked default, so the value survives the reader and vanishes
|
|
84
|
+
# from the document -- measured on a required attribute (`to_json`
|
|
85
|
+
# rendered `{"message":"m"}` for an Issue whose `severity` still
|
|
86
|
+
# read back "info", and reloading that JSON raised
|
|
87
|
+
# `ValidationError: Missing required attribute: severity`) and
|
|
88
|
+
# separately on optional, unguarded ones (`source_path`, `format`,
|
|
89
|
+
# `Issue#code` all round-tripped to nil having read back their real
|
|
90
|
+
# value right up to the freeze).
|
|
91
|
+
#
|
|
92
|
+
# None of these models declares `default:`, so a genuine default is
|
|
93
|
+
# always blank -- nil for a scalar, empty for a collection, once
|
|
94
|
+
# `normalize` has run (which it has by here). A NON-blank value
|
|
95
|
+
# under a true `using_default?` is therefore always the bookkeeping
|
|
96
|
+
# lying about a value that IS present, never a legitimate default,
|
|
97
|
+
# whether or not the attribute is required.
|
|
98
|
+
def validate_default_bookkeeping(name, attribute)
|
|
99
|
+
return unless using_default?(name)
|
|
100
|
+
|
|
101
|
+
value = public_send(name)
|
|
102
|
+
blank = attribute.collection? ? value.empty? : value.nil?
|
|
103
|
+
return if blank
|
|
104
|
+
|
|
105
|
+
refuse(name, "recorded as explicitly set", "flagged as using its default")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# JSON has neither Infinity nor NaN. lutaml coerces both without
|
|
109
|
+
# complaint -- measured: `{"width":1e400}` deserialized to
|
|
110
|
+
# Float::INFINITY, the lifecycle froze the model around it, and the
|
|
111
|
+
# `to_json` that followed raised `JSON::GeneratorError`, so a
|
|
112
|
+
# document that parsed could not be written back out. Refused here,
|
|
113
|
+
# where the attribute name is still in hand.
|
|
114
|
+
def validate_finite(name)
|
|
115
|
+
value = public_send(name)
|
|
116
|
+
return unless value.is_a?(Numeric) && !value.finite?
|
|
117
|
+
|
|
118
|
+
refuse(name, "a finite number", value)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Lutaml's generated getter consults a scalar enum's backing Array
|
|
122
|
+
# before `validate_types`. Own a core copy first so virtual `first`
|
|
123
|
+
# cannot show validation a different value from the one later
|
|
124
|
+
# frozen and rendered.
|
|
125
|
+
def own_enum_storage(name, attribute, raw)
|
|
126
|
+
return raw if attribute.collection? || !attribute.enum?
|
|
127
|
+
return raw if core_instance?(raw, ::NilClass) ||
|
|
128
|
+
Lutaml::Model::Utils.uninitialized?(raw)
|
|
129
|
+
|
|
130
|
+
refuse(name, "a core Array", class_of(raw)) unless core_instance?(raw, ::Array)
|
|
131
|
+
|
|
132
|
+
copy = []
|
|
133
|
+
each_array(raw) { |value| copy << own_enum_value(name, attribute, value) }
|
|
134
|
+
object_frozen?(self) ? raw : copy
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def own_enum_value(name, attribute, value)
|
|
138
|
+
return value if Lutaml::Model::Utils.uninitialized?(value)
|
|
139
|
+
return value unless attribute.type == Lutaml::Model::Type::String
|
|
140
|
+
|
|
141
|
+
refuse(name, "a String enum value", class_of(value)) unless inherits_from?(value, ::String)
|
|
142
|
+
own_string(name, value)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# No `unless value.is_a?(Array)` guard here any more: lutaml-model
|
|
146
|
+
# 0.8.32 (lutaml/lutaml-model#185) auto-wraps ANY non-Array value
|
|
147
|
+
# handed to a `collection: true` attribute into a one-element Array
|
|
148
|
+
# before this runs, at both construction and deserialization --
|
|
149
|
+
# `Report.new(issues: some_issue)` and
|
|
150
|
+
# `Report.from_json({"issues":{...}})` both now read back
|
|
151
|
+
# `issues == [some_issue]`. The shape check can no longer fire; only
|
|
152
|
+
# the per-element type check below still can, unchanged.
|
|
153
|
+
def validate_attribute(name, attribute, type)
|
|
154
|
+
value = public_send(name)
|
|
155
|
+
return validate_type(name, type, value, nullable: true) unless attribute.collection?
|
|
156
|
+
|
|
157
|
+
value.each { |element| validate_type(name, type, element, nullable: false) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# A singular model attribute may be absent; a collection member may
|
|
161
|
+
# not -- a nil there reaches recursion and dies as a NoMethodError.
|
|
162
|
+
def validate_type(name, type, element, nullable:)
|
|
163
|
+
return if nullable && element.nil?
|
|
164
|
+
return if element.is_a?(type)
|
|
165
|
+
|
|
166
|
+
refuse(name, type, element.class)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# lutaml-model 0.8.19 validates nothing on its own: a bogus enum
|
|
171
|
+
# constructs and serializes, a missing attribute becomes nil, and
|
|
172
|
+
# `validate!` does not reach nested models. Every model therefore runs
|
|
173
|
+
# one lifecycle -- normalize, validate, freeze -- at both doors.
|
|
174
|
+
class Base < Lutaml::Model::Serializable
|
|
175
|
+
include Validation
|
|
176
|
+
|
|
177
|
+
DESERIALIZING = :claricle_models_deserializing
|
|
178
|
+
# Lutaml builds every model -- parent and nested alike -- by calling
|
|
179
|
+
# `new` with exactly this and populating it afterwards. Direct
|
|
180
|
+
# construction always carries real attributes.
|
|
181
|
+
BLANK_CONSTRUCTION = [:lutaml_register].freeze
|
|
182
|
+
private_constant :DESERIALIZING, :BLANK_CONSTRUCTION
|
|
183
|
+
|
|
184
|
+
# Public on lutaml, for its own XML allocation path
|
|
185
|
+
# (`allocate_for_deserialization`, reached only through an `xml`
|
|
186
|
+
# mapping block) to reset every attribute to a shared empty-
|
|
187
|
+
# collection sentinel or `UninitializedClass.instance` before
|
|
188
|
+
# filling it in field by field. None of these models declares an
|
|
189
|
+
# `xml` block, so lutaml never calls either method on one -- but a
|
|
190
|
+
# caller reached them too, through the block form of `new`, before
|
|
191
|
+
# this runs. Measured: `init_deserialization_state(:default)` wiped
|
|
192
|
+
# a Report's `source_path` to the sentinel and its whole `issues`
|
|
193
|
+
# collection to `[]`, and the model sealed reporting `valid: :yes`
|
|
194
|
+
# over an issue the caller had actually supplied -- no schema
|
|
195
|
+
# violation to catch, since the wiped shape is indistinguishable
|
|
196
|
+
# from a Report the caller genuinely built empty. Hidden rather
|
|
197
|
+
# than checked for, because the collection wipe leaves no value
|
|
198
|
+
# anywhere in the object for `validate_types` to catch.
|
|
199
|
+
private :init_deserialization_state, :finalize_deserialization
|
|
200
|
+
|
|
201
|
+
# Deserialization builds a blank instance and populates it afterwards,
|
|
202
|
+
# so `initialize` must not finalize during that window. Neither the
|
|
203
|
+
# key nor the value is secret; the protection is that BOTH the extent
|
|
204
|
+
# and lutaml's blank signature must hold, so ordinary construction
|
|
205
|
+
# validates however the thread-local was set. Forging both yields an
|
|
206
|
+
# empty model, which `allocate` already gives anyone who wants one.
|
|
207
|
+
# `Thread.current[]` is fiber-local, so a boundary it does not cross
|
|
208
|
+
# makes construction validate rather than skip.
|
|
209
|
+
#
|
|
210
|
+
# `of` rather than `from`: every `from_*` parses and then delegates
|
|
211
|
+
# here, so this one override also covers `of_json` and friends. A
|
|
212
|
+
# top-level array arrives already finalized, because `of` maps it back
|
|
213
|
+
# through itself one item at a time.
|
|
214
|
+
def self.of(format, doc, options = {})
|
|
215
|
+
outer = Thread.current[DESERIALIZING]
|
|
216
|
+
Thread.current[DESERIALIZING] = true
|
|
217
|
+
begin
|
|
218
|
+
result = super
|
|
219
|
+
ensure
|
|
220
|
+
Thread.current[DESERIALIZING] = outer
|
|
221
|
+
end
|
|
222
|
+
result.is_a?(Array) ? result : result.finalize
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Lutaml normally deletes bytes it cannot transcode before assigning
|
|
226
|
+
# them. Preserve those bytes so the name-aware lifecycle below can
|
|
227
|
+
# report why the declared String is unusable instead of calling a
|
|
228
|
+
# required value missing.
|
|
229
|
+
def self.ensure_utf8(value)
|
|
230
|
+
return super unless value.is_a?(::String) && !Lutaml::Model.opal?
|
|
231
|
+
|
|
232
|
+
value.encode(Encoding::UTF_8)
|
|
233
|
+
rescue EncodingError
|
|
234
|
+
value
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Lutaml also accepts a positional attributes Hash, so take whatever
|
|
238
|
+
# the superclass takes.
|
|
239
|
+
# Both conditions are required. Inside a deserialization but with
|
|
240
|
+
# real attributes means a caller built this themselves -- through a
|
|
241
|
+
# coercion callback, say -- so it validates now. Outside one, the
|
|
242
|
+
# thread-local is meaningless however it was set.
|
|
243
|
+
def initialize(*args, **kwargs)
|
|
244
|
+
super
|
|
245
|
+
finalize unless args.empty? &&
|
|
246
|
+
kwargs.keys == BLANK_CONSTRUCTION &&
|
|
247
|
+
Thread.current[DESERIALIZING]
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def initialize_copy(other)
|
|
251
|
+
super
|
|
252
|
+
@claricle_sealed = false
|
|
253
|
+
finalize
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def finalize
|
|
257
|
+
validate_deeply
|
|
258
|
+
seal
|
|
259
|
+
self
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def validate_deeply
|
|
263
|
+
clear_uninitialized
|
|
264
|
+
normalize
|
|
265
|
+
validate!
|
|
266
|
+
validate_types
|
|
267
|
+
nested_models.each(&:validate_deeply)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
protected
|
|
271
|
+
|
|
272
|
+
# Idempotent on our own bookkeeping rather than on `frozen?`, so a
|
|
273
|
+
# model frozen by other code is not mistaken for a sealed one. An
|
|
274
|
+
# empty model with a required attribute never reaches here --
|
|
275
|
+
# validation rejects it first; Report and Location have none, so an
|
|
276
|
+
# empty instance of either is valid and does reach this point. A
|
|
277
|
+
# populated one that dies does so partway through, which is the
|
|
278
|
+
# externally-frozen case tested below.
|
|
279
|
+
#
|
|
280
|
+
# The marker goes on last, beside the freeze it stands for. Set
|
|
281
|
+
# first, a child that refused to seal left the parent marked sealed
|
|
282
|
+
# and still mutable, and every later `seal` returned at the guard --
|
|
283
|
+
# measured: the block form of `new` handed back a Report that called
|
|
284
|
+
# itself sealed, took another issue, and changed its own verdict.
|
|
285
|
+
# There is no cycle for an early marker to break: nesting runs
|
|
286
|
+
# Report -> Issue -> Location and stops.
|
|
287
|
+
#
|
|
288
|
+
# Protected, not public: it freezes without revalidating, so a
|
|
289
|
+
# caller holding a model that failed to finalize (the block form of
|
|
290
|
+
# `new` hands one over before the exception) could mutate it into
|
|
291
|
+
# any shape and freeze it straight past `validate_deeply` -- measured,
|
|
292
|
+
# a poisoned Issue sealed with `severity: "bogus"` and serialized.
|
|
293
|
+
# `finalize` is the only public door, and it always validates first.
|
|
294
|
+
# Explicit block form for the recursive call below: `Symbol#to_proc`
|
|
295
|
+
# calls `public_send`, which a protected method refuses.
|
|
296
|
+
def seal
|
|
297
|
+
return self if @claricle_sealed
|
|
298
|
+
|
|
299
|
+
# rubocop:disable Style/SymbolProc -- &:seal calls public_send,
|
|
300
|
+
# which a protected method refuses; the block calls it directly.
|
|
301
|
+
nested_models.each { |model| model.seal }
|
|
302
|
+
# rubocop:enable Style/SymbolProc
|
|
303
|
+
freeze_attributes
|
|
304
|
+
# Lutaml tracks which attributes still hold defaults in a mutable
|
|
305
|
+
# hash, and `using_default_for` is public. Flipping an entry on a
|
|
306
|
+
# frozen model drops that attribute from the document -- measured:
|
|
307
|
+
# marking every one of an issue's fields turns "issues":[...] into
|
|
308
|
+
# "issues":null, and a warning report then reloads valid. Freezing
|
|
309
|
+
# it here, after the attribute pass, leaves composition intact.
|
|
310
|
+
instance_variable_get(:@using_default)&.freeze
|
|
311
|
+
@claricle_sealed = true
|
|
312
|
+
freeze
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
private
|
|
316
|
+
|
|
317
|
+
# lutaml has a sentinel object standing for "never set", and the
|
|
318
|
+
# block form of `new` is the one door that can put it in a SINGULAR
|
|
319
|
+
# attribute's ivar. Through the five others -- keyword, positional,
|
|
320
|
+
# `from_hash`, `from_json`, and nested inside a parent -- lutaml
|
|
321
|
+
# erases it before our lifecycle runs and the ivar holds a plain
|
|
322
|
+
# nil. That is why the doors are made to agree by normalising
|
|
323
|
+
# rather than by refusing: a refusal is only reachable from one of
|
|
324
|
+
# the six.
|
|
325
|
+
#
|
|
326
|
+
# Measured on 0.8.19, the block form disagreed with the rest and
|
|
327
|
+
# with itself. `Location.new { |l| l.byte_offset(sentinel) }` raised
|
|
328
|
+
# `ValidationError: byte_offset expects a non-negative integer, got
|
|
329
|
+
# uninitialized`, while `Location.new { |l| l.chunk(sentinel) }`
|
|
330
|
+
# sealed and handed the sentinel itself back out of `chunk`, where
|
|
331
|
+
# a caller expects a String or nil. Worse on a required attribute:
|
|
332
|
+
# `Issue.new(severity: "error") { |i| i.message(sentinel) }` sealed
|
|
333
|
+
# and rendered `{"severity":"error"}`, a document missing a field
|
|
334
|
+
# it cannot be missing, which reloaded as `Missing required
|
|
335
|
+
# attribute: message`. Cleared to nil, the required check sees it.
|
|
336
|
+
#
|
|
337
|
+
# A COLLECTION never reaches here: lutaml wraps whatever the setter
|
|
338
|
+
# is given, so the ivar holds `[sentinel]` rather than the sentinel
|
|
339
|
+
# and `uninitialized?` is false. `validate_types` refuses that on
|
|
340
|
+
# its own -- measured, `issues expects Claricle::Models::Issue, got
|
|
341
|
+
# Lutaml::Model::UninitializedClass` -- which is the right answer
|
|
342
|
+
# for a member of a typed collection and not this method's job.
|
|
343
|
+
#
|
|
344
|
+
# A no-op on every other path. Measured across 16 shapes -- all
|
|
345
|
+
# four models, empty and fully populated, through `new`,
|
|
346
|
+
# `from_json`, `from_yaml`, `from_hash`, `of_json`, `dup` and
|
|
347
|
+
# nested inside a parent -- it cleared nothing in any of them.
|
|
348
|
+
def clear_uninitialized
|
|
349
|
+
self.class.attributes.each do |name, attribute|
|
|
350
|
+
slot = :"@#{name}"
|
|
351
|
+
original = instance_variable_get(slot)
|
|
352
|
+
raw = own_enum_storage(name, attribute, original)
|
|
353
|
+
instance_variable_set(slot, raw) unless raw.equal?(original)
|
|
354
|
+
next unless Lutaml::Model::Utils.uninitialized?(stored_value(raw, attribute))
|
|
355
|
+
|
|
356
|
+
instance_variable_set(slot, nil)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# lutaml wraps a singular enum's value in an Array, so its sentinel
|
|
361
|
+
# arrives as `[sentinel]` and identity against the singleton walks
|
|
362
|
+
# straight past it. The same wrapper `own_enum_storage` reads
|
|
363
|
+
# through, and the same guard it uses to find it.
|
|
364
|
+
#
|
|
365
|
+
# Measured before this looked inside: `Inspection.new { |x|
|
|
366
|
+
# x.parse_status = sentinel }` sealed and rendered `{"issues":[]}`
|
|
367
|
+
# -- a required attribute absent from the document -- and reloading
|
|
368
|
+
# that raised `Missing required attribute: parse_status`. Every
|
|
369
|
+
# other door refused it outright. `Issue#severity` did the same.
|
|
370
|
+
#
|
|
371
|
+
# A collection's `[sentinel]` is deliberately left alone: there the
|
|
372
|
+
# Array is the value, not a wrapper, and `validate_types` refuses
|
|
373
|
+
# the member on its own.
|
|
374
|
+
def stored_value(raw, attribute)
|
|
375
|
+
return raw if attribute.collection? || !attribute.enum?
|
|
376
|
+
return raw unless core_instance?(raw, ::Array) && array_size(raw) == 1
|
|
377
|
+
|
|
378
|
+
array_first(raw)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Refused, rather than supported. Ruby's default Marshal writes the
|
|
382
|
+
# ivars into a fresh object and runs neither the constructor nor the
|
|
383
|
+
# lifecycle -- measured: the copy came back unfrozen, took
|
|
384
|
+
# `severity = "bogus"` past the enum, and rendered it. Nothing in
|
|
385
|
+
# this gem forks, caches or crosses a process boundary, so no model
|
|
386
|
+
# is dumped at all rather than dumped through a protocol of its own.
|
|
387
|
+
# A real one can be written the day something actually needs it.
|
|
388
|
+
#
|
|
389
|
+
# This closes the dump side only, and does not pretend otherwise.
|
|
390
|
+
# `Marshal.load` of bytes a caller got from somewhere else still
|
|
391
|
+
# builds whatever those bytes describe -- measured, a hand-built
|
|
392
|
+
# ordinary-object payload naming this class loaded unfrozen with
|
|
393
|
+
# `severity: "bogus"`. That was equally true before this refusal
|
|
394
|
+
# existed: `_load` was only ever reached for payloads our own
|
|
395
|
+
# `_dump` had written, never for a payload someone else shaped.
|
|
396
|
+
# Nothing in this gem calls `Marshal.load`.
|
|
397
|
+
#
|
|
398
|
+
# `_dump` and not `marshal_dump`: Ruby prefers `marshal_dump` where
|
|
399
|
+
# both exist, so the refusal sits on the hook a later `marshal_dump`
|
|
400
|
+
# would have to displace deliberately. Private, because Marshal
|
|
401
|
+
# reaches a private `_dump` on the class and on every subclass --
|
|
402
|
+
# measured -- so nothing is gained by publishing it. It covers a
|
|
403
|
+
# model nested inside a plain Hash or Array too, also measured.
|
|
404
|
+
def _dump(_depth)
|
|
405
|
+
raise TypeError, "cannot marshal #{self.class}: Claricle models are not marshalable"
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Freeze declared attributes only, and freeze them where they lie.
|
|
409
|
+
# Inspection owns and freezes its free-form Hash before this pass.
|
|
410
|
+
# Works on the backing storage, not the getters. An enum declared with
|
|
411
|
+
# `values:` is stored as a mutable Array behind a String getter, so
|
|
412
|
+
# freezing what the getter returns leaves that array writable and a
|
|
413
|
+
# frozen report can still have its verdict changed. Writing the ivar
|
|
414
|
+
# rather than calling the setter also leaves lutaml's `using_default`
|
|
415
|
+
# bookkeeping alone.
|
|
416
|
+
#
|
|
417
|
+
# Strings are duplicated first: `new` hands us its own copy, but
|
|
418
|
+
# `from_hash` aliases the caller's, and freezing that would take their
|
|
419
|
+
# String away from them.
|
|
420
|
+
def freeze_attributes
|
|
421
|
+
self.class.attributes.each_key do |name|
|
|
422
|
+
slot = :"@#{name}"
|
|
423
|
+
raw = instance_variable_get(slot)
|
|
424
|
+
if inherits_from?(raw, ::String)
|
|
425
|
+
instance_variable_set(slot, own_string(name, raw))
|
|
426
|
+
elsif core_instance?(raw, ::Array)
|
|
427
|
+
instance_variable_set(slot, own_array(name, raw))
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def own_array(name, value)
|
|
433
|
+
copy = []
|
|
434
|
+
each_array(value) { |element| copy << own_string(name, element) }
|
|
435
|
+
copy.freeze
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def own_string(name, value)
|
|
439
|
+
return value unless inherits_from?(value, ::String)
|
|
440
|
+
|
|
441
|
+
copy = ::String.new(value)
|
|
442
|
+
JSON.generate(copy)
|
|
443
|
+
copy.freeze
|
|
444
|
+
rescue JSON::GeneratorError => e
|
|
445
|
+
refuse(name, "a String JSON can render", e.message)
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def normalize; end
|
|
449
|
+
|
|
450
|
+
def nested_models
|
|
451
|
+
[]
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Validation is an implementation detail of Base, not an extension
|
|
456
|
+
# point -- Base is private, so a mixin only Base uses has no caller
|
|
457
|
+
# left to serve.
|
|
458
|
+
private_constant :Base, :Validation
|
|
459
|
+
end
|
|
460
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "report"
|
|
5
|
+
require_relative "conversion"
|
|
6
|
+
require_relative "uncoerced_integer"
|
|
7
|
+
|
|
8
|
+
module Claricle
|
|
9
|
+
module Models
|
|
10
|
+
# Why this is not a Models::Issue: an Issue is the document's problem,
|
|
11
|
+
# and this is the operation's. A file that could not be opened, or whose
|
|
12
|
+
# format nothing handles, has no conformance verdict at all -- collapsing
|
|
13
|
+
# the two would blur the one line the envelope exists to draw.
|
|
14
|
+
class BatchError < Base
|
|
15
|
+
attribute :code, :string, required: true
|
|
16
|
+
attribute :message, :string, required: true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# One slot of a batch, and the only element type a batch ever holds. A
|
|
20
|
+
# heterogeneous array of results and failures would force a consumer to
|
|
21
|
+
# type-switch on whether a field is present, and `jq '.[].result.valid'`
|
|
22
|
+
# would answer null for an operational failure -- indistinguishable from
|
|
23
|
+
# a genuine null.
|
|
24
|
+
class BatchItem < Base
|
|
25
|
+
# The codes a process status can carry, so this cannot record one the
|
|
26
|
+
# shell would misread. The same range Cli::Runner::Status enforces.
|
|
27
|
+
CODES = (0..255)
|
|
28
|
+
private_constant :CODES
|
|
29
|
+
|
|
30
|
+
attribute :path, :string, required: true
|
|
31
|
+
# Recomputed in `normalize` from the two fields below, so whatever a
|
|
32
|
+
# caller or a document supplies here is overwritten before the model
|
|
33
|
+
# seals. Three values, not two: "the operation ran and said no" and
|
|
34
|
+
# "the operation could not run" are different answers, and the second
|
|
35
|
+
# is why an operational failure must never read as a verdict.
|
|
36
|
+
#
|
|
37
|
+
# Report#valid derives the same kind of summary through a `with:`
|
|
38
|
+
# mapping instead. That route is closed here: lutaml 0.8.22 invokes a
|
|
39
|
+
# `with:` mapping's `from` method on a throwaway instance built by
|
|
40
|
+
# `mapper_class.new` with no arguments at all
|
|
41
|
+
# (mapping_rule.rb:412-417), and Base's lifecycle validates that
|
|
42
|
+
# instance -- measured, `BatchItem.new` raises ValidationError on the
|
|
43
|
+
# two required attributes above. Report has no required attribute, so
|
|
44
|
+
# it never met this. A `to`-only mapping is refused outright
|
|
45
|
+
# ("`:with` argument for mapping 'status' requires :to and :from").
|
|
46
|
+
attribute :status, :string, values: %w[ok failed error]
|
|
47
|
+
attribute :exit_code, UncoercedInteger, required: true
|
|
48
|
+
# A union rather than a plain `Report`, now that a batch can also hold
|
|
49
|
+
# a conversion. Lutaml resolves which member a Hash/JSON value is by
|
|
50
|
+
# key coverage (`Union.conform_model`, lutaml-model union.rb:163-180):
|
|
51
|
+
# every input key must belong to the member's mapped fields. Report and
|
|
52
|
+
# Conversion both map "source_path", but are disjoint on every OTHER
|
|
53
|
+
# field -- Report also maps "format"/"issues"/"valid", Conversion also
|
|
54
|
+
# maps "source_format"/"target_format"/"lossiness"/"output_path" -- so
|
|
55
|
+
# a real document built by either one always carries at least one of
|
|
56
|
+
# those and resolves unambiguously. Only a Hash bearing "source_path"
|
|
57
|
+
# and nothing else would be genuinely ambiguous, and nothing in this
|
|
58
|
+
# codebase builds one. Declared order does not matter for any real
|
|
59
|
+
# input; Report is listed first as it is the existing, more common
|
|
60
|
+
# case.
|
|
61
|
+
#
|
|
62
|
+
attribute :result, [Report, Conversion]
|
|
63
|
+
alias cast_result= result=
|
|
64
|
+
private :cast_result=
|
|
65
|
+
|
|
66
|
+
# Lutaml casts a value matching neither union member to nil. Refuse it
|
|
67
|
+
# here while the caller's original value is still available.
|
|
68
|
+
def result=(value)
|
|
69
|
+
self.cast_result = value
|
|
70
|
+
validate_result(value)
|
|
71
|
+
value
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
attribute :error, BatchError
|
|
75
|
+
|
|
76
|
+
# Keyword and positional-hash construction cast union values before
|
|
77
|
+
# calling the writer, so retain the original until casting finishes.
|
|
78
|
+
def initialize(*args, **kwargs)
|
|
79
|
+
supplied_result = result_argument(args, kwargs)
|
|
80
|
+
super
|
|
81
|
+
validate_result(supplied_result)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def result_argument(args, kwargs)
|
|
87
|
+
return kwargs[:result] if kwargs.key?(:result)
|
|
88
|
+
|
|
89
|
+
attributes = args.first
|
|
90
|
+
attributes[:result] if attributes.is_a?(::Hash) && attributes.key?(:result)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate_result(value)
|
|
94
|
+
return if value.nil? || result.is_a?(Report) || result.is_a?(Conversion)
|
|
95
|
+
|
|
96
|
+
refuse(:result, "a Report or Conversion", value.class)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The verdict is written here rather than read from what arrived, so a
|
|
100
|
+
# document cannot assert a status its own fields contradict, and the
|
|
101
|
+
# stored value cannot drift from them.
|
|
102
|
+
def normalize
|
|
103
|
+
self.status = derived_status
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def derived_status
|
|
107
|
+
return "error" if error
|
|
108
|
+
return "ok" if exit_code.is_a?(::Integer) && exit_code.zero?
|
|
109
|
+
|
|
110
|
+
"failed"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The declared type keeps the caller's value intact precisely so this
|
|
114
|
+
# can tell `"3"` from the `3` lutaml would have made of it.
|
|
115
|
+
def validate_types
|
|
116
|
+
super
|
|
117
|
+
return if exit_code.is_a?(::Integer) && CODES.cover?(exit_code)
|
|
118
|
+
|
|
119
|
+
refuse(:exit_code, "an Integer in #{CODES}", exit_code.inspect)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def nested_models
|
|
123
|
+
[result, error].compact
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|