lutaml-model 0.8.65 → 0.8.67

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8bbc0a4da8114a582ee834a333a8614ca1051747296a1e967eb36f47ef68b48
4
- data.tar.gz: ec01b4058ae9ce9d400f229ae4f116bcdde25cdd015e3b96ff3e2b39af62ff10
3
+ metadata.gz: 52c9d5f90fb80dbbec116f4a6e8d65e92d8b5fe3181a9a4b7aa26349b64d8938
4
+ data.tar.gz: 657ef667d7d9192a6f6d6592168c8bf5c3a3f25aeff8fa4127d4438954ac7bf0
5
5
  SHA512:
6
- metadata.gz: 494b45969f4e8f67e5f79fe09eb6e1630e5b04109fe46b13210cc8dfe455a11e7b7d7955a91ae2132c037c7e53d21fb09c0462a55899cccaafc4d4e5b609bccd
7
- data.tar.gz: 6694a345fda4b371988fab2af07b9311b7eed87f2e61a696eab7c2a247ba04950bbb93439b1019a27903dbfcf8c5815067f2d3449a41affe2cdb21a6a3deb583
6
+ metadata.gz: 14797b4e4855ac206622648c2ae5cf01b5115e9a243f461861d90e78903a80df356d35144b024801dd9556ed3d89fe526069157faf5bceb804088829be768be4
7
+ data.tar.gz: 179a58214dc093680917167055d3f3deec644303b310c91763d27c6aed18b19fc1e5668dea0c8a1a1e3542ef54b8d39712ade45a869ee0c072f1bb92ae10c18f
@@ -106,6 +106,13 @@ Lutaml::Model::Config.toml_adapter_type = :teptris
106
106
  raising `Psych::DisallowedClass`.
107
107
  * **JSON**: `yeptris` parsing targets exact `JSON.parse` semantics;
108
108
  generation stays on the `json` gem.
109
+ * **Error classes**: parse failures keep the standard adapters' contract
110
+ on every engine — the yeptris adapters re-raise the engine's syntax
111
+ errors as `Psych::SyntaxError` (YAML, with the engine's line, column,
112
+ and problem text) and `JSON::ParserError` (JSON), and the model layer
113
+ wraps them as `InvalidFormatError` just as it does for Psych and the
114
+ `json` gem. Code that rescues those classes does not change when a
115
+ native engine is added to the bundle.
109
116
  * **TOML**: the teptris adapter mirrors tomlib value semantics — offset
110
117
  and local datetimes become `Time`, dates become `Date`, local times
111
118
  stay `String`. Parse failures raise `Teptris::ParseError` with line
@@ -12,9 +12,32 @@ module Lutaml
12
12
  # load-only), so everything else inherits from the standard adapter.
13
13
  class YeptrisAdapter < StandardAdapter
14
14
  def self.parse(json, _options = {})
15
- require "yeptris"
15
+ require_engine
16
16
  ::Yeptris::JSON.load(json)
17
+ rescue ::Yeptris::JSON::ParseError => e
18
+ raise parser_error(e)
19
+ end
20
+
21
+ # The engine require sits outside parse's rescue so a broken
22
+ # install still surfaces its LoadError (the detection fallback's
23
+ # contract) instead of a NameError from the rescue clause.
24
+ def self.require_engine
25
+ require "yeptris"
26
+ end
27
+ private_class_method :require_engine
28
+
29
+ # The parse-error contract is the standard adapter's: invalid
30
+ # input raises JSON::ParserError regardless of the engine, so
31
+ # consumers' rescue ladders (and this gem's own
32
+ # format_error_types conversion to InvalidFormatError) hold on
33
+ # every adapter. Without the json gem the engine error
34
+ # propagates unchanged.
35
+ def self.parser_error(error)
36
+ return error unless defined?(::JSON::ParserError)
37
+
38
+ ::JSON::ParserError.new(error.message)
17
39
  end
40
+ private_class_method :parser_error
18
41
  end
19
42
  end
20
43
  end
@@ -566,10 +566,15 @@ instance_object = nil)
566
566
  end
567
567
 
568
568
  def default_value(register, instance_object = nil)
569
- if delegate
570
- type(register).attributes(register)[to].default(register,
571
- instance_object)
572
- elsif options[:default].is_a?(Proc)
569
+ # The kind is fixed at definition; the common case (no default
570
+ # at all) paid three option checks per absent attribute per
571
+ # instantiation before the memo.
572
+ case (@default_kind ||= _default_kind)
573
+ when :none
574
+ Lutaml::Model::UninitializedClass.instance
575
+ when :static
576
+ options[:default]
577
+ when :proc
573
578
  if instance_object
574
579
  ::Lutaml::Model::Attribute.evaluating_default do
575
580
  instance_object.instance_exec(&options[:default])
@@ -577,13 +582,20 @@ instance_object = nil)
577
582
  else
578
583
  options[:default].call
579
584
  end
580
- elsif options.key?(:default)
581
- options[:default]
582
- else
583
- Lutaml::Model::UninitializedClass.instance
585
+ else # :delegate
586
+ type(register).attributes(register)[to].default(register,
587
+ instance_object)
584
588
  end
585
589
  end
586
590
 
591
+ def _default_kind
592
+ return :delegate if delegate
593
+ return :proc if options[:default].is_a?(Proc)
594
+ return :static if options.key?(:default)
595
+
596
+ :none
597
+ end
598
+
587
599
  # Marks the dynamic extent in which a default proc runs against a
588
600
  # model instance, so Serialize#method_missing can fall back to the
589
601
  # model class for methods the instance does not define (0.7.x
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.65"
5
+ VERSION = "0.8.67"
6
6
  end
7
7
  end
@@ -66,14 +66,14 @@ module Lutaml
66
66
  def attributes_kwargs(plan, value, node = nil)
67
67
  kwargs = {}
68
68
  sole_claimants = sole_claimant_names(plan)
69
- plan[:attr_rows].each do |rule, attr|
70
- v = value.attribute(rule.name.to_s)
71
- if v.nil? && node && sole_claimants.include?(rule.name.to_s)
69
+ plan_attr_rows(plan).each do |rule, attr, name|
70
+ v = value.attribute(name)
71
+ if v.nil? && node && sole_claimants.include?(name)
72
72
  # #754/#790 parity: a sole-claimant attribute rule binds
73
73
  # any qualification (the interpretive matcher's lenient
74
74
  # recovery). The walk captures exact (URI, local) matches
75
75
  # only, so the qualified spelling is read off the node.
76
- v = lenient_node_attribute(node, rule.name.to_s)
76
+ v = lenient_node_attribute(node, name)
77
77
  end
78
78
  next if v.nil?
79
79
 
@@ -86,6 +86,15 @@ module Lutaml
86
86
  kwargs
87
87
  end
88
88
 
89
+ # Wire-name strings are plan-frozen; materializing them per
90
+ # hydration allocated two or three strings per attribute row
91
+ # per instance.
92
+ def plan_attr_rows(plan)
93
+ plan[:attr_row_names] ||= plan[:attr_rows].map do |rule, attr|
94
+ [rule, attr, rule.name.to_s]
95
+ end
96
+ end
97
+
89
98
  # Returns [kwargs, hydrated_child_instances, delegate_values]
90
99
  # — child instances come back so the caller can decorate
91
100
  # parent/root links once the parent exists; delegate values
@@ -364,10 +373,11 @@ module Lutaml
364
373
  # names eligible for #754/#790 lenient binding (multi-claimant
365
374
  # names stay exact; per #841 leniency is sole-claimant-only).
366
375
  def sole_claimant_names(plan)
367
- plan[:attr_rows].map { |rule, _attr| rule.name.to_s }
368
- .tally
369
- .select { |_n, c| c == 1 }
370
- .keys
376
+ # Frozen with the plan: recomputing the tally per hydration
377
+ # showed in the #876-era profile as Enumerable#find/tally churn.
378
+ plan[:sole_claimants] ||= plan[:attr_rows]
379
+ .map { |rule, _attr| rule.name.to_s }.tally
380
+ .select { |_n, c| c == 1 }.keys
371
381
  end
372
382
 
373
383
  # Local-name attribute lookup on the source node, qualified
@@ -391,9 +401,12 @@ module Lutaml
391
401
  want_tags = !row_tags.nil?
392
402
  value.count.times do |i|
393
403
  child = value.at(i)
394
- next if child.name.nil? # content runs, read separately
404
+ # One FFI crossing per child: the name was read twice here
405
+ # (nil check + bucket key), doubling the hottest accessor.
406
+ name = child.name
407
+ next if name.nil? # content runs, read separately
395
408
 
396
- (grouped[child.name] ||= []) << child
409
+ (grouped[name] ||= []) << child
397
410
  if want_tags && child.type_tag != 0
398
411
  (tagged[child.type_tag] ||= []) << child
399
412
  end
@@ -14,18 +14,50 @@ module Lutaml
14
14
  # Semantic notes vs the standard adapter's Psych.safe_load:
15
15
  # implicit typing is parity-verified (compat_11 schema); anchors
16
16
  # and aliases always resolve; a tagged value without a core type
17
- # materializes as plain data instead of raising DisallowedClass.
17
+ # materializes as plain data instead of raising DisallowedClass;
18
+ # parse failures surface as Psych::SyntaxError like the standard
19
+ # adapter's (the engine's own ParseError is re-raised under the
20
+ # standard class).
18
21
  class YeptrisAdapter < StandardAdapter
19
22
  # yeptris is required lazily: the ruby-platform variant installs
20
23
  # everywhere but only loads where libyeptris exists (no Windows
21
24
  # prebuilts) — a broken install must degrade to detection
22
25
  # fallback, never crash loads.
23
26
  def self.parse(yaml, _options = {})
24
- require "yeptris"
27
+ require_engine
25
28
  require "yeptris/yaml"
26
29
  # rubocop:disable-next Naming/VariableNumber -- upstream schema literal
27
30
  Yeptris::YAML.load(yaml, schema: :compat_11)
31
+ rescue Yeptris::ParseError => e
32
+ raise syntax_error(e)
33
+ end
34
+
35
+ # The engine require sits outside parse's rescue so a broken
36
+ # install still surfaces its LoadError (the detection fallback's
37
+ # contract) instead of a NameError from the rescue clause.
38
+ def self.require_engine
39
+ require "yeptris"
40
+ end
41
+ private_class_method :require_engine
42
+
43
+ # The parse-error contract is the standard adapter's: invalid
44
+ # input raises Psych::SyntaxError regardless of the engine, so
45
+ # consumers' rescue ladders (and this gem's own
46
+ # format_error_types conversion to InvalidFormatError) hold on
47
+ # every adapter. The C parser reports the position inside the
48
+ # error message ("... at line L, column C"); lift it into the
49
+ # structured fields. Without psych (Opal) the engine error
50
+ # propagates unchanged.
51
+ def self.syntax_error(error)
52
+ return error unless defined?(::Psych::SyntaxError)
53
+
54
+ match = /\bline (\d+),? column (\d+)/.match(error.message)
55
+ line = match ? match[1].to_i : 0
56
+ column = match ? match[2].to_i : 0
57
+ problem = error.message.sub(/\s+at\s+line\s+\d+,?\s+column\s+\d+\z/, "")
58
+ ::Psych::SyntaxError.new(nil, line, column, 0, problem, nil)
28
59
  end
60
+ private_class_method :syntax_error
29
61
 
30
62
  def to_yaml(_options = {})
31
63
  attributes_to_serialize = if @attributes.is_a?(Lutaml::KeyValue::DataModel::Element)
@@ -25,9 +25,9 @@ RSpec.describe(YEPTRIS_AVAILABLE ? Lutaml::Json::Adapter::YeptrisAdapter : Objec
25
25
  .to eq("a" => 1.5, "b" => nil, "c" => true, "d" => "1e3")
26
26
  end
27
27
 
28
- it "raises on invalid JSON" do
28
+ it "raises JSON::ParserError on invalid JSON, like the standard adapter" do
29
29
  expect { described_class.parse("{nope") }
30
- .to raise_error(Yeptris::JSON::ParseError)
30
+ .to raise_error(JSON::ParserError)
31
31
  end
32
32
  end
33
33
 
@@ -59,5 +59,10 @@ RSpec.describe(YEPTRIS_AVAILABLE ? Lutaml::Json::Adapter::YeptrisAdapter : Objec
59
59
  expect(model.roles).to eq(%w[admin dev])
60
60
  expect(model_class.from_json(model.to_json).roles).to eq(%w[admin dev])
61
61
  end
62
+
63
+ it "surfaces invalid JSON as InvalidFormatError" do
64
+ expect { model_class.from_json("{nope") }
65
+ .to raise_error(Lutaml::Model::InvalidFormatError)
66
+ end
62
67
  end
63
68
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "spec_helper"
5
+
6
+ # The suite deliberately never loads the optional yeptris engine
7
+ # in-process (adapter_resolver_configured_spec asserts the resolver's
8
+ # lazy-detection guarantee), so the error-class contract is exercised
9
+ # in a subprocess: wherever the engine is installed it is the
10
+ # preferred adapter, and its parse failures must surface under the
11
+ # standard adapters' error classes — Psych::SyntaxError and
12
+ # JSON::ParserError from the adapters, Lutaml::Model::InvalidFormatError
13
+ # through a model — so downstream rescue ladders hold on every engine.
14
+ RSpec.describe "yeptris adapter error parity" do
15
+ # A direct ruby invocation with explicit load paths and NO shell:
16
+ # a nested `bundle exec` produced empty output on some CI legs, and
17
+ # backticks route through cmd.exe on Windows where POSIX quoting
18
+ # mangles the command. Open3 with array arguments is shell-free on
19
+ # every platform.
20
+ subject(:outcomes) do
21
+ libs = %w[lutaml-model yeptris yeptris-ruby moxml leptris].filter_map do |g|
22
+ "#{Gem::Specification.find_by_name(g).gem_dir}/lib"
23
+ rescue Gem::LoadError
24
+ nil
25
+ end
26
+ args = libs.flat_map { |l| ["-I", l] } + ["-e", probe]
27
+ Open3.capture2e(Gem.ruby, *args).first
28
+ end
29
+
30
+ def yeptris_installed?
31
+ Gem::Specification.find_by_name("yeptris")
32
+ true
33
+ rescue Gem::LoadError
34
+ false
35
+ end
36
+
37
+ # The probe requires the engine explicitly (the resolved adapters
38
+ # prove the preference), then reports one outcome line per surface.
39
+ let(:probe) do
40
+ <<~RUBY
41
+ require "lutaml/model"
42
+
43
+ def outcome
44
+ yield
45
+ "NO-ERROR"
46
+ rescue StandardError => e
47
+ "\#{e.class.name}: \#{e.message}"
48
+ end
49
+
50
+ puts "yaml-adapter: \#{Lutaml::Model::AdapterResolver.adapter_for(:yaml).name}"
51
+ puts "json-adapter: \#{Lutaml::Model::AdapterResolver.adapter_for(:json).name}"
52
+
53
+ puts "adapter-yaml: \#{outcome do
54
+ Lutaml::Yaml::Adapter::YeptrisAdapter.parse("name: test\\n invalid: [unclosed\\n")
55
+ end }"
56
+ puts "adapter-json: \#{outcome do
57
+ Lutaml::Json::Adapter::YeptrisAdapter.parse("{nope")
58
+ end }"
59
+
60
+ model_class = Class.new(Lutaml::Model::Serializable) do
61
+ attribute :name, :string
62
+ key_value { map "name", to: :name }
63
+ end
64
+ puts "model-yaml: \#{outcome do
65
+ model_class.from_yaml("name: test\\n invalid: [unclosed\\n")
66
+ end }"
67
+ puts "model-json: \#{outcome do
68
+ model_class.from_json("{nope")
69
+ end }"
70
+ RUBY
71
+ end
72
+
73
+ before { skip "yeptris is not installed" unless yeptris_installed? }
74
+
75
+ it "prefers the yeptris adapters when the engine is installed" do
76
+ expect(outcomes).to include("yaml-adapter: Lutaml::Yaml::Adapter::YeptrisAdapter")
77
+ expect(outcomes).to include("json-adapter: Lutaml::Json::Adapter::YeptrisAdapter")
78
+ end
79
+
80
+ it "raises Psych::SyntaxError from the yaml adapter for invalid YAML" do
81
+ expect(outcomes).to match(/^adapter-yaml: Psych::SyntaxError: .+ at line \d+/)
82
+ end
83
+
84
+ it "raises JSON::ParserError from the json adapter for invalid JSON" do
85
+ expect(outcomes).to match(/^adapter-json: JSON::ParserError: /)
86
+ end
87
+
88
+ it "raises InvalidFormatError through a model for invalid YAML" do
89
+ expect(outcomes).to match(/^model-yaml: Lutaml::Model::InvalidFormatError: /)
90
+ end
91
+
92
+ it "raises InvalidFormatError through a model for invalid JSON" do
93
+ expect(outcomes).to match(/^model-json: Lutaml::Model::InvalidFormatError: /)
94
+ end
95
+ end
@@ -30,6 +30,30 @@ RSpec.describe(YEPTRIS_AVAILABLE ? Lutaml::Yaml::Adapter::YeptrisAdapter : Objec
30
30
  expect(described_class.parse("d: 2020-01-01\n"))
31
31
  .to eq("d" => Date.new(2020, 1, 1))
32
32
  end
33
+
34
+ it "raises Psych::SyntaxError for invalid YAML, like the standard adapter" do
35
+ expect { described_class.parse("name: test\n invalid: [unclosed\n") }
36
+ .to raise_error(Psych::SyntaxError)
37
+ end
38
+
39
+ it "raises Psych::SyntaxError for a mapping value inside a plain scalar" do
40
+ # The dependent-CI shape: one line, a second "key:" colon after a
41
+ # plain scalar run. Psych rejects it with "mapping values are not
42
+ # allowed in this context"; the engine must surface under the
43
+ # same class so downstream rescue ladders hold.
44
+ expect { described_class.parse('name: test\n invalid: [unclosed') }
45
+ .to raise_error(Psych::SyntaxError)
46
+ end
47
+
48
+ it "carries the engine's position on the syntax error" do
49
+ expect { described_class.parse("name: test\n invalid: [unclosed\n") }
50
+ .to raise_error(Psych::SyntaxError) do |error|
51
+ expect(error.line).to be >= 1
52
+ expect(error.problem).to be_a(String)
53
+ expect(error.problem).not_to be_empty
54
+ expect(error.message).to include("at line #{error.line}")
55
+ end
56
+ end
33
57
  end
34
58
 
35
59
  describe "#to_yaml" do
@@ -59,5 +83,10 @@ RSpec.describe(YEPTRIS_AVAILABLE ? Lutaml::Yaml::Adapter::YeptrisAdapter : Objec
59
83
  expect(model.roles).to eq(%w[admin dev])
60
84
  expect(model_class.from_yaml(model.to_yaml).roles).to eq(%w[admin dev])
61
85
  end
86
+
87
+ it "surfaces invalid YAML as InvalidFormatError" do
88
+ expect { model_class.from_yaml("name: test\n invalid: [unclosed\n") }
89
+ .to raise_error(Lutaml::Model::InvalidFormatError)
90
+ end
62
91
  end
63
92
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.65
4
+ version: 0.8.67
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-26 00:00:00.000000000 Z
11
+ date: 2026-09-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -1880,6 +1880,7 @@ files:
1880
1880
  - spec/lutaml/model/yamls_range_spec.rb
1881
1881
  - spec/lutaml/model/yamls_sequence_spec.rb
1882
1882
  - spec/lutaml/model/yamls_spec.rb
1883
+ - spec/lutaml/model/yeptris_error_parity_spec.rb
1883
1884
  - spec/lutaml/model_spec.rb
1884
1885
  - spec/lutaml/rdf/context_spec.rb
1885
1886
  - spec/lutaml/rdf/graph_serialization_spec.rb