From 1582ed5572f64bcc6b02cc6bae9b20dec6b79f9d Mon Sep 17 00:00:00 2001 From: Gustavo Date: Fri, 31 Jul 2026 18:23:59 -0300 Subject: [PATCH 1/7] improvements YARD docs --- lib/sdf/xml.rb | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/lib/sdf/xml.rb b/lib/sdf/xml.rb index 66ed023..1c1656c 100644 --- a/lib/sdf/xml.rb +++ b/lib/sdf/xml.rb @@ -165,7 +165,7 @@ def self.gazebo_models(sdf_version = nil) # @raise (see model_path_of) # @raise [NoSuchModel] if the provided model name does not resolve to a # model in {model_path} - # @return [REXML::Element] + # @return [String] the path to the SDF file for the model def self.model_path_from_name(model_name, model_path: @model_path, sdf_version: nil) @gazebo_models[sdf_version] ||= {} cache = (@gazebo_models[sdf_version][model_name] ||= ModelCacheEntry.new) @@ -213,6 +213,23 @@ def self.model_from_name( end end + # Resolves relative paths and model:// URIs in the XML tree in-place + # + # This method traverses the XML tree starting from the given node, and + # expands any relative paths or `model://` URIs inside `` tags to + # absolute paths on the local filesystem. + # + # It skips `` tags because those are resolved separately during + # {.add_include_tags}. + # + # @example Replaces a model:// mesh path: + # # Before: model://robot_model/hull.dae + # # After: /path/to/workspace/robot_models/models/sdf/robot_model/hull.dae + # + # @param [REXML::Element] node the XML element to traverse + # @!macro sdf_version + # @param [String] base_path the base directory path used to resolve relative paths + # @return [void] def self.resolve_relative_uris(node, sdf_version, base_path) nodes = [node] until nodes.empty? @@ -264,6 +281,24 @@ def self.deep_copy_xml(node) # This method modifies the XML tree by replacing the include tags found # as direct children of the provided element by the included content. # + # @example + # # Before calling add_include_tags: + # # + # # + # # model://my_sensor + # # custom_sensor + # # 1 0 0 0 0 0 + # # + # # + # # + # # After calling add_include_tags: + # # + # # + # # 1 0 0 0 0 0 + # # ... + # # + # # + # # @param [REXML::Element] elem element to find include tags # @!macro sdf_version # @return [void] From e95debca936379f121d0450e67086940fc6e7288 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Fri, 31 Jul 2026 18:24:52 -0300 Subject: [PATCH 2/7] fix expected paths in test_xml.rb --- test/test_xml.rb | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/test/test_xml.rb b/test/test_xml.rb index ee5724f..31e5879 100644 --- a/test/test_xml.rb +++ b/test/test_xml.rb @@ -169,10 +169,12 @@ def invalid_models_dir sdf = SDF::XML.load_sdf(File.join(models_dir, "model_with_relative_file_in_uri", "model.sdf")) uri = sdf.elements.to_a("//uri").first - assert_equal( - File.join(models_dir, "model_with_relative_file_in_uri", - "visual.dae"), uri.text + expected_full_path = File.expand_path( + File.join( + models_dir, "model_with_relative_file_in_uri", "visual.dae" + ) ) + assert_equal(expected_full_path, uri.text) end it "resolves relative paths to other model's paths in tags" do sdf = SDF::XML.load_sdf(File.join(models_dir, @@ -184,10 +186,10 @@ def invalid_models_dir sdf = SDF::XML.load_sdf(File.join(models_dir, "model_that_includes_a_model_with_relative_paths", "model.sdf")) uri = sdf.elements.to_a("//uri").first - assert_equal( - File.join(models_dir, "model_with_relative_uris", - "visual.dae"), uri.text + expected_full_path = File.expand_path( + File.join(models_dir, "model_with_relative_uris", "visual.dae") ) + assert_equal(expected_full_path, uri.text) end it "resolves model:// in tags" do sdf = SDF::XML.load_sdf(File.join(models_dir, @@ -204,9 +206,9 @@ def invalid_models_dir metadata: true ) - model_full_path = File.expand_path(File.join( - "data", "models", "simple_model", "model.sdf" - ), __dir__) + model_full_path = File.join( + models_dir, "simple_model", "model.sdf" + ) expected = [ "w::child_of_world", "w::model::child_of_model", @@ -214,7 +216,6 @@ def invalid_models_dir "root_model::child_of_root_model", "root_model::model_in_root_model::child_of_model_in_root_model" ] - assert_equal [model_full_path], metadata["includes"].keys assert_equal expected.sort, metadata["includes"][model_full_path].sort @@ -227,12 +228,10 @@ def invalid_models_dir metadata: true ) - ur10_full_path = File.expand_path(File.join( - "data", "regressions", "ur10", "ur10.sdf" - ), __dir__) - dual_ur10_full_path = File.expand_path(File.join( - "data", "regressions", "dual_ur10", "model.sdf" - ), __dir__) + ur10_full_path = File.join(regressions_dir, "ur10", "ur10.sdf") + dual_ur10_full_path = File.join( + regressions_dir, "dual_ur10", "model.sdf" + ) expected = Hash[ ur10_full_path => %w[ empty_world::dual_ur10_fixed::dual_ur10::left_arm From 2d3553458dc490d8c512a3dd7b13528d148ff207 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Fri, 31 Jul 2026 18:31:19 -0300 Subject: [PATCH 3/7] fallback to model.sdf.erb when there is no model.sdf file --- lib/sdf/erb.rb | 46 +++ lib/sdf/xml.rb | 33 ++- .../data/models/simple_model_erb/model.config | 5 + .../models/simple_model_erb/model.sdf.erb | 39 +++ test/test_erb.rb | 266 ++++++++++++++++++ test/test_xml.rb | 15 +- 6 files changed, 394 insertions(+), 10 deletions(-) create mode 100644 lib/sdf/erb.rb create mode 100644 test/data/models/simple_model_erb/model.config create mode 100644 test/data/models/simple_model_erb/model.sdf.erb create mode 100644 test/test_erb.rb diff --git a/lib/sdf/erb.rb b/lib/sdf/erb.rb new file mode 100644 index 0000000..83cddf2 --- /dev/null +++ b/lib/sdf/erb.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "erb" + +module SDF + # Module for handling ERB files + module ERB + module_function + + # Open an .erb file and returns its content as a string + # + # @param [String] file_path path to the .erb template file + # @return [String] erb file content as a string + # + # @raise [ArgumentError] if the file path is not a .erb file, + # is invalid, or is unreadable + def read_erb_file(file_path) + unless file_path.end_with?(".erb") + raise ArgumentError, + "Provided file path must have a '.erb' extension: #{file_path}" + end + + begin + erb_content = File.read(file_path) + rescue Errno::ENOENT + raise ArgumentError, "ERB template file not found at: #{file_path}" + rescue Errno::EACCES + raise ArgumentError, + "Permission denied reading ERB template at: #{file_path}" + end + erb_content + end + + # Parses an ERB string and returns the raw rendered string + # + # @param [String] erb_content ERB template file content as string + # @param [Hash] erb_args the configuration arguments to evaluate + # @return [String] the raw rendered XML string representing the model + def parse_erb_as_str(erb_content, **erb_args) + erb_engine = ::ERB.new(erb_content, trim_mode: "-") + + # Render the ERB template with the passed hash arguments + erb_engine.result_with_hash(erb_args) + end + end +end diff --git a/lib/sdf/xml.rb b/lib/sdf/xml.rb index 1c1656c..faf0f6c 100644 --- a/lib/sdf/xml.rb +++ b/lib/sdf/xml.rb @@ -1,4 +1,5 @@ require "rexml/document" +require_relative "erb" module SDF module XML @@ -406,24 +407,38 @@ def self.add_include_tags(elem, sdf_version, base_path) # @raise [Errno::ENOENT] if the files does not exist # @raise [NotSDF] if the file is not a SDF file # @raise [InvalidXML] if the file is not a valid XML file + # @raise [NoSuchModel] if cannot opend sdf_file nor a erb templated sdf_file # @return [REXML::Element] def self.load_sdf_raw(sdf_file) - sdf = File.open(sdf_file) do |io| - REXML::Document.new(io) + erb_file = sdf_file.end_with?(".erb") ? sdf_file : "#{sdf_file}.erb" + + unless File.exist?(sdf_file) || File.exist?(erb_file) + file_name = File.basename(sdf_file) + dir_path = File.dirname(sdf_file) + raise Errno::ENOENT, + "Cannot find '#{file_name}' or '#{file_name}.erb' in '#{dir_path}'. " \ + "You probably want to update the GAZEBO_MODEL_PATH environment variable, " \ + "or set SDF.model_path explicitly." + end + + begin + sdf = if File.exist?(sdf_file) && !sdf_file.end_with?(".erb") + File.open(sdf_file) { |io| REXML::Document.new(io) } + else + REXML::Document.new(SDF::ERB.parse_erb_as_str(SDF::ERB.read_erb_file(erb_file))) + end rescue REXML::ParseException => e - unless e.message.match?(/No root/) - raise InvalidXML, "cannot load #{sdf_file}: #{e.message}" + unless e.message.include?("No root") + raise InvalidXML, "Cannot load #{sdf_file}: #{e.message}" end - REXML::Document.new + sdf = REXML::Document.new end unless sdf.root - raise NotSDF, - "#{sdf_file} can be parsed as an XML file, but it does not have a root" + raise NotSDF, "#{sdf_file} can be parsed as an XML file, but it does not have a root" end - - if sdf.root.name != "sdf" && sdf.root.name != "gazebo" + unless %w[sdf gazebo].include?(sdf.root.name) raise NotSDF, "#{sdf_file} is not a SDF file" end diff --git a/test/data/models/simple_model_erb/model.config b/test/data/models/simple_model_erb/model.config new file mode 100644 index 0000000..d84a0c8 --- /dev/null +++ b/test/data/models/simple_model_erb/model.config @@ -0,0 +1,5 @@ + + + simple_model + model.sdf + diff --git a/test/data/models/simple_model_erb/model.sdf.erb b/test/data/models/simple_model_erb/model.sdf.erb new file mode 100644 index 0000000..05ec12d --- /dev/null +++ b/test/data/models/simple_model_erb/model.sdf.erb @@ -0,0 +1,39 @@ + +<% + default_gps_pose = [-0.679, 0.0, 1.920, 0.0, 0.0, 0.0] + default_gps2_pose = [2.571, 0.044, 0.808, 0.0, 0.0, 0.0] + + gps1_pose = (defined?(gps_sensors) && gps_sensors.dig(:gps, :pose)) || default_gps_pose + gps2_pose = (defined?(gps_sensors) && gps_sensors.dig(:gps2, :pose)) || default_gps2_pose +%> + + + + + + + + root + child + + + + + + <%= gps1_pose.join(' ') %> + + + root + gps + + + + <%= gps2_pose.join(' ') %> + + + root + gps2 + + + + diff --git a/test/test_erb.rb b/test/test_erb.rb new file mode 100644 index 0000000..5dec053 --- /dev/null +++ b/test/test_erb.rb @@ -0,0 +1,266 @@ +# frozen_string_literal: true + +require "sdf/erb" + +describe SDF::ERB do + it "read_erb_file" do + template_path = File.expand_path("data/models/simple_model_erb/model.sdf.erb", + __dir__) + + sdf_str = SDF::ERB.read_erb_file(template_path) + + assert sdf_str + + expected_sdf_str = <<~XML + + <% + default_gps_pose = [-0.679, 0.0, 1.920, 0.0, 0.0, 0.0] + default_gps2_pose = [2.571, 0.044, 0.808, 0.0, 0.0, 0.0] + gps1_pose = (defined?(gps_sensors) && gps_sensors.dig(:gps, :pose)) || default_gps_pose + gps2_pose = (defined?(gps_sensors) && gps_sensors.dig(:gps2, :pose)) || default_gps2_pose + %> + + + + + + + + root + child + + + + + + <%= gps1_pose.join(' ') %> + + + root + gps + + + + <%= gps2_pose.join(' ') %> + + + root + gps2 + + + + XML + + formatted_str = sdf_str.gsub(/\s+/, " ").strip + formatted_expected = expected_sdf_str.gsub(/\s+/, " ").strip + + assert_equal(formatted_expected, formatted_str) + end + + it "read_erb_file_wrong_extension" do + assert_raises(ArgumentError) do + SDF::ERB.read_erb_file("data/models/simple_model_erb/model.config") + end + end + + it "read_erb_file_do_not_exist" do + assert_raises(ArgumentError) do + SDF::ERB.read_erb_file("/tmp/i_do_not_exist_i_hope.erb") + end + end + + it "parse_erb_as_str" do + erb_content = <<~XML + + + + + + + + + root + child + + + + + <% gps_sensors.each do |gps| %> + + <%= gps[:pose].join(' ') %> + + + root + <%= gps[:name] %> + + <% end %> + + + + + + + XML + + erb_args = { + model_name: "my_model_name", + gps_sensors: [ + { + name: "gps", + pose: [-0.679, 0.0, 1.920, 0.0, 0.0, 0.0] + }, + { + name: "gps2", + pose: [2.571, 0.044, 0.808, 0, 0, 0] + } + ] + } + resulting_sdf = SDF::ERB.parse_erb_as_str(erb_content, **erb_args) + + expected_content = <<~XML + + + + + + + + + root + child + + + + + + -0.679 0.0 1.92 0.0 0.0 0.0 + + + root + gps + + + + 2.571 0.044 0.808 0 0 0 + + + root + gps2 + + + + + + + + XML + + formatted_erb = resulting_sdf.gsub(/\s+/, " ").strip + formatted_expected = expected_content.gsub(/\s+/, " ").strip + + assert_equal(formatted_expected, formatted_erb) + end + + it "parse_erb_as_str_with_extra_unused_args" do + erb_content = <<~XML + + + + + + + + + root + child + + + + + <% gps_sensors.each do |gps| %> + + <%= gps[:pose].join(' ') %> + + + root + <%= gps[:name] %> + + <% end %> + + + + + + + XML + + erb_args = { + model_name: "my_model_name", + gps_sensors: [ + { + name: "gps", + pose: [-0.679, 0.0, 1.920, 0.0, 0.0, 0.0] + }, + { + name: "gps2", + pose: [2.571, 0.044, 0.808, 0, 0, 0] + } + ], + random_key: "random_value", + random_array: [1, 2, 3], + random_hash: { key1: "value1", key2: "value2" } + } + resulting_sdf = SDF::ERB.parse_erb_as_str(erb_content, **erb_args) + + expected_content = <<~XML + + + + + + + + + root + child + + + + + + -0.679 0.0 1.92 0.0 0.0 0.0 + + + root + gps + + + + 2.571 0.044 0.808 0 0 0 + + + root + gps2 + + + + + + + + XML + + formatted_erb = resulting_sdf.gsub(/\s+/, " ").strip + formatted_expected = expected_content.gsub(/\s+/, " ").strip + + assert_equal(formatted_expected, formatted_erb) + end + + it "parse_erb_as_str_raises_on_missing_args" do + erb_content = "" + # Missing :model_name in erb_args + assert_raises(NameError) do + SDF::ERB.parse_erb_as_str(erb_content) + end + end +end diff --git a/test/test_xml.rb b/test/test_xml.rb index 31e5879..dd37463 100644 --- a/test/test_xml.rb +++ b/test/test_xml.rb @@ -60,7 +60,7 @@ def invalid_models_dir describe "gazebo_models" do it "loads all models available in the path" do models = SDF::XML.gazebo_models - assert_equal 23, models.size + assert_equal 24, models.size assert(sdf = models["simple_model"]) model = sdf.elements.enum_for(:each, "sdf/model").first @@ -401,6 +401,19 @@ def sdf_model_in_model_that_replaces_pose_in_include end end + describe "load_sdf with a ERB templated model" do + it "loads SDF file fallbacks to ERB when model.sdf doesn't exist" do + sdf = SDF::XML.load_sdf_raw(File.join(models_dir, "simple_model_erb", "model.sdf")) + model = sdf.elements.enum_for(:each, "sdf/model").first + assert_equal("simple_model_erb", model.attributes["name"]) + end + it "loads ERB templated file" do + sdf = SDF::XML.load_sdf_raw(File.join(models_dir, "simple_model_erb", "model.sdf.erb")) + model = sdf.elements.enum_for(:each, "sdf/model").first + assert_equal("simple_model_erb", model.attributes["name"]) + end + end + describe "model_from_name" do it "resolves and returns the raw model" do sdf = SDF::XML.model_from_name("simple_model") From 4301929c28b4060a041e021d8bbcc24f92244517 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 6 Aug 2026 16:37:16 -0300 Subject: [PATCH 4/7] add render_erb_sdf_model to erb module --- lib/sdf/erb.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/sdf/erb.rb b/lib/sdf/erb.rb index 83cddf2..b235ee5 100644 --- a/lib/sdf/erb.rb +++ b/lib/sdf/erb.rb @@ -42,5 +42,15 @@ def parse_erb_as_str(erb_content, **erb_args) # Render the ERB template with the passed hash arguments erb_engine.result_with_hash(erb_args) end + + # Renders an ERB template and returns it as a REXML::Document + # + # @return [REXML::Document] the rendered sdf model + def render_erb_sdf_model(path, **erb_args) + erb_content = read_erb_file(path) + solved_erb_as_sdf_str = parse_erb_as_str(erb_content, **erb_args) + + REXML::Document.new(solved_erb_as_sdf_str) + end end end From 5739d8c8ae8d7c92994034dcc0873bee9c64b02a Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 6 Aug 2026 16:38:08 -0300 Subject: [PATCH 5/7] move XML exceptions to exceptions.rb --- lib/sdf/exceptions.rb | 21 +++++++++++++++++++++ lib/sdf/xml.rb | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/sdf/exceptions.rb b/lib/sdf/exceptions.rb index 5efb385..a0cb2b6 100644 --- a/lib/sdf/exceptions.rb +++ b/lib/sdf/exceptions.rb @@ -1,3 +1,24 @@ module SDF class InternalError < RuntimeError; end + + module XML + # Exception raised when trying to load a model URI, but the model does + # not contain a SDF entry for the required SDF version + class UnavailableSDFVersionInModel < ArgumentError; end + # Exception raised when trying to load a file that is not a SDF file + class NotSDF < ArgumentError; end + # Exception raised when trying to load a malformed XML file + class InvalidXML < ArgumentError; end + + # Exception raised when trying to resolve a model that cannot be found + # in {model_path} + class NoSuchModel < ArgumentError + attr_reader :model_name + + def initialize(model_name) + super + @model_name = model_name + end + end + end end diff --git a/lib/sdf/xml.rb b/lib/sdf/xml.rb index faf0f6c..5581849 100644 --- a/lib/sdf/xml.rb +++ b/lib/sdf/xml.rb @@ -1,5 +1,5 @@ require "rexml/document" -require_relative "erb" +require_relative "exceptions" module SDF module XML From 9548488eb2aea6c4c58f22fcde5189fb9a76bea3 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 6 Aug 2026 16:43:01 -0300 Subject: [PATCH 6/7] add SDF::Loader that works for both .sdf and .sdf.erb --- lib/sdf/root.rb | 10 +- lib/sdf/sdf_loader.rb | 142 +++++++++++ lib/sdf/xml.rb | 93 ++----- .../models/simple_model_erb/model.sdf.erb | 7 +- test/test_erb.rb | 15 +- test/test_root.rb | 5 +- test/test_sdf_loader.rb | 238 ++++++++++++++++++ test/test_xml.rb | 13 - 8 files changed, 419 insertions(+), 104 deletions(-) create mode 100644 lib/sdf/sdf_loader.rb create mode 100644 test/test_sdf_loader.rb diff --git a/lib/sdf/root.rb b/lib/sdf/root.rb index cdbabba..5d7035c 100644 --- a/lib/sdf/root.rb +++ b/lib/sdf/root.rb @@ -28,12 +28,12 @@ def initialize(xml, metadata = {}) # @raise [XML::NotSDF] if the file is not a SDF file # @raise [XML::InvalidXML] if the file is not a valid XML file # @return [Root] - def self.load(sdf_file, expected_sdf_version = nil, flatten: true) + def self.load(sdf_file, expected_sdf_version = nil, flatten: true, loader: SDF::Loader.new) if sdf_file =~ %r{^model://(.*)} load_from_model_name(::Regexp.last_match(1), expected_sdf_version, - flatten: flatten) + flatten: flatten, loader: loader) else - xml, metadata = XML.load_sdf(sdf_file, flatten: flatten, metadata: true) + xml, metadata = XML.load_sdf(sdf_file, flatten: flatten, metadata: true, loader: loader) new(xml.root, metadata) end end @@ -48,9 +48,9 @@ def self.load(sdf_file, expected_sdf_version = nil, flatten: true) # (as version * 100, i.e. version 1.5 is represented by 150). Leave to # nil to always read the latest. # @return [Root] - def self.load_from_model_name(model_name, sdf_version = nil, flatten: true) + def self.load_from_model_name(model_name, sdf_version = nil, flatten: true, loader: SDF::Loader.new) xml, metadata = XML.model_from_name(model_name, sdf_version, - flatten: flatten, metadata: true) + flatten: flatten, metadata: true, loader: loader) new(xml.root, metadata) end diff --git a/lib/sdf/sdf_loader.rb b/lib/sdf/sdf_loader.rb new file mode 100644 index 0000000..4ea6a72 --- /dev/null +++ b/lib/sdf/sdf_loader.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require_relative "erb" +require_relative "exceptions" + +module SDF + # class to load SDF and ERB templated SDF files + class Loader + # Represents the configuration for rendering a single ERB template. + class ModelTemplate + attr_reader :model, :erb_args + + def initialize( + model, + erb_args: {} + ) + @model = model + @erb_args = (erb_args || {}).transform_keys(&:to_sym) + end + + def self.from_hash(hash) + # Convert string keys to symbols to support string-keyed hashes + symbolized = hash.transform_keys(&:to_sym) + model = symbolized.fetch(:model) do + raise ArgumentError, + "The :model key is required when constructing from a Hash" + end + options = symbolized.reject { |k| k == :model } + new(model, **options) + end + + def model_name + if @model.start_with?("model://") + @model.sub("model://", "").split("/").first + elsif @model.end_with?(".sdf") || @model.end_with?(".sdf.erb") + File.basename(File.dirname(@model)) + else + File.basename(@model) + end + end + end + + # Initializes the loader with one or more templates. + # + # Supports: + # - ERBLoader.new(model: "...", erb_args: ...) + # - ERBLoader.new({ model: "a" }, { model: "b" }) + # - ERBLoader.new([{ model: "a" }, { model: "b" }]) + # - ERBLoader.new(ModelTemplate.new("a"), ModelTemplate.new("b")) + def initialize(*templates) + # Unwrap the outer array if multiple templates were passed + # inside an explicit Array + if templates.size == 1 && templates.first.kind_of?(Array) + templates = templates.first + end + @templates = templates.map do |template| + case template + when ModelTemplate + template + when Hash + ModelTemplate.from_hash(template) + when String + ModelTemplate.new(template) + else + raise ArgumentError, + "Expected SDF::Loader::ModelTemplate, Hash, or " \ + "String, got #{template.class}" + end + end + end + + def render_erb_sdf_model(sdf_file) + target_model_name = File.basename(File.dirname(sdf_file)) + model_config = @templates.find { |t| t.model_name == target_model_name } + erb_args = model_config&.erb_args || {} + SDF::ERB.render_erb_sdf_model(sdf_file, **erb_args) + end + + # Open a SDF file or ERB templated SDF file and returns the XML representation. + # + # The input files must have `.sdf` or `.sdf.erb` as its extension + # + # @param [String] sdf_file the path to the SDF file + # @raise [Errno::ENOENT] if the files does not exist + # @raise [NotSDF] if the file is not a SDF file + # @raise [InvalidXML] if the file is not a valid XML file + # @return [REXML::Element] sdf_file's content as a REXML::Element instance + def load_sdf_raw(sdf_file) + erb_file = sdf_file.end_with?(".sdf.erb") ? sdf_file : "#{sdf_file}.erb" + find_or_raise_file_not_found(sdf_file, erb_file) + + sdf = parse_sdf_document(sdf_file, erb_file) + validate_sdf_root(sdf, sdf_file) + + sdf + end + + private + + def find_or_raise_file_not_found(sdf_file, erb_file) + return if File.exist?(sdf_file) || File.exist?(erb_file) + + file_name = File.basename(sdf_file) + dir_path = File.dirname(sdf_file) + msg = if sdf_file.end_with?(".sdf.erb") + "Cannot find '#{file_name}' in '#{dir_path}'. " + else + "Cannot find '#{file_name}' or '#{file_name}.erb' in " \ + "'#{dir_path}'. " + end + raise Errno::ENOENT, + "#{msg}You probably want to update the GAZEBO_MODEL_PATH " \ + "environment variable, or set SDF.model_path explicitly." + end + + def parse_sdf_document(sdf_file, erb_file) + if File.exist?(sdf_file) && !sdf_file.end_with?(".erb") + File.open(sdf_file) { |io| REXML::Document.new(io) } + else + render_erb_sdf_model(erb_file) + end + rescue REXML::ParseException => e + unless e.message.include?("No root") + raise SDF::XML::InvalidXML, + "Cannot load #{sdf_file}: #{e.message}" + end + + REXML::Document.new + end + + def validate_sdf_root(sdf, sdf_file) + unless sdf.root + raise SDF::XML::NotSDF, + "#{sdf_file} can be parsed as an XML file, but it " \ + "does not have a root" + end + return if %w[sdf gazebo].include?(sdf.root.name) + + raise SDF::XML::NotSDF, "#{sdf_file} is not a SDF file" + end + end +end diff --git a/lib/sdf/xml.rb b/lib/sdf/xml.rb index 5581849..6505494 100644 --- a/lib/sdf/xml.rb +++ b/lib/sdf/xml.rb @@ -1,5 +1,6 @@ require "rexml/document" require_relative "exceptions" +require_relative "sdf_loader" module SDF module XML @@ -8,24 +9,6 @@ module XML # (as version * 100, i.e. version 1.5 is represented by 150). Leave to # nil to always read the latest. - # Exception raised when trying to load a model URI, but the model does - # not contain a SDF entry for the required SDF version - class UnavailableSDFVersionInModel < ArgumentError; end - # Exception raised when trying to load a file that is not a SDF file - class NotSDF < ArgumentError; end - # Exception raised when trying to load a malformed XML file - class InvalidXML < ArgumentError; end - - # Exception raised when trying to resolve a model that cannot be found - # in {model_path} - class NoSuchModel < ArgumentError - attr_reader :model_name - - def initialize(model_name) - @model_name = model_name - end - end - # The search path for models # # It defaults to GAZEBO_MODEL_PATH @@ -84,9 +67,9 @@ def self.clear_cache # SDF file for the required SDF version # # @return [REXML::Element] - def self.load_gazebo_model(dir, sdf_version = nil, metadata: false, flatten: true) + def self.load_gazebo_model(dir, sdf_version = nil, metadata: false, flatten: true, loader: SDF::Loader.new) load_sdf(model_path_of(dir, sdf_version), metadata: metadata, - flatten: flatten) + flatten: flatten, loader: loader) end # Find model string into model.config path @@ -129,7 +112,7 @@ def self.model_path_of(dir, sdf_version = nil) # # @!macro sdf_version # @return [Hash] - def self.gazebo_models(sdf_version = nil) + def self.gazebo_models(sdf_version = nil, loader: SDF::Loader.new) @gazebo_models[sdf_version] ||= {} @model_path.each do |p| Dir.glob(File.join(p, "*")) do |subdir| @@ -141,7 +124,7 @@ def self.gazebo_models(sdf_version = nil) begin sdf_file_path = model_path_of(subdir, sdf_version) sdf, metadata = load_sdf(sdf_file_path, metadata: true, - flatten: false) + flatten: false, loader: loader) @gazebo_models[sdf_version][File.basename(subdir)] = ModelCacheEntry.new(sdf_file_path, sdf, metadata) rescue UnavailableSDFVersionInModel @@ -194,12 +177,12 @@ def self.model_path_from_name(model_name, model_path: @model_path, sdf_version: # model in {model_path} # @return [REXML::Element] def self.model_from_name( - model_name, sdf_version = nil, metadata: false, flatten: true + model_name, sdf_version = nil, metadata: false, flatten: true, loader: SDF::Loader.new ) path = model_path_from_name(model_name, sdf_version: sdf_version) cache = @gazebo_models[sdf_version][model_name] unless cache.xml - cache.xml, cache.metadata = load_sdf(path, metadata: true, flatten: false) + cache.xml, cache.metadata = load_sdf(path, metadata: true, flatten: false, loader: loader) end xml = cache.xml if flatten @@ -303,13 +286,13 @@ def self.deep_copy_xml(node) # @param [REXML::Element] elem element to find include tags # @!macro sdf_version # @return [void] - def self.add_include_tags(elem, sdf_version, base_path) + def self.add_include_tags(elem, sdf_version, base_path, loader: SDF::Loader.new) includes = {} replacements = [] elem.elements.each do |inc| if inc.name == "world" || inc.name == "model" # model-within-model - added_includes = add_include_tags(inc, sdf_version, base_path) + added_includes = add_include_tags(inc, sdf_version, base_path, loader: loader) includes.merge! added_includes do |_, old, new| old + new end @@ -345,11 +328,11 @@ def self.add_include_tags(elem, sdf_version, base_path) included_sdf, included_metadata = model_from_name(model_name, sdf_version, metadata: true, - flatten: false) + flatten: false, loader: loader) elsif File.directory?(uri_path = File.expand_path(uri, base_path)) included_sdf, included_metadata = load_gazebo_model(uri_path, sdf_version, metadata: true, - flatten: false) + flatten: false, loader: loader) else raise ArgumentError, "URI #{uri} is neither a model:// URI nor an existing directory" @@ -398,53 +381,6 @@ def self.add_include_tags(elem, sdf_version, base_path) includes end - # Open a SDF file and returns the XML representation - # - # Unlike {.load_sdf}, this really only loads the XML information, not - # resolving the include tags. - # - # @param [String] sdf_file the path to the SDF file - # @raise [Errno::ENOENT] if the files does not exist - # @raise [NotSDF] if the file is not a SDF file - # @raise [InvalidXML] if the file is not a valid XML file - # @raise [NoSuchModel] if cannot opend sdf_file nor a erb templated sdf_file - # @return [REXML::Element] - def self.load_sdf_raw(sdf_file) - erb_file = sdf_file.end_with?(".erb") ? sdf_file : "#{sdf_file}.erb" - - unless File.exist?(sdf_file) || File.exist?(erb_file) - file_name = File.basename(sdf_file) - dir_path = File.dirname(sdf_file) - raise Errno::ENOENT, - "Cannot find '#{file_name}' or '#{file_name}.erb' in '#{dir_path}'. " \ - "You probably want to update the GAZEBO_MODEL_PATH environment variable, " \ - "or set SDF.model_path explicitly." - end - - begin - sdf = if File.exist?(sdf_file) && !sdf_file.end_with?(".erb") - File.open(sdf_file) { |io| REXML::Document.new(io) } - else - REXML::Document.new(SDF::ERB.parse_erb_as_str(SDF::ERB.read_erb_file(erb_file))) - end - rescue REXML::ParseException => e - unless e.message.include?("No root") - raise InvalidXML, "Cannot load #{sdf_file}: #{e.message}" - end - - sdf = REXML::Document.new - end - - unless sdf.root - raise NotSDF, "#{sdf_file} can be parsed as an XML file, but it does not have a root" - end - unless %w[sdf gazebo].include?(sdf.root.name) - raise NotSDF, "#{sdf_file} is not a SDF file" - end - - sdf - end - # Get sdf_version # # @param [REXML::Element] sdf element @@ -491,6 +427,9 @@ def self.sdf_version_of(sdf) # @param [Boolean] metadata whether the method should return a metadata hash # about the various inclusions that have been performed. See above for # the hash format + # @param [#load_sdf_raw] loader object that acts as a loader.Takes a file path as input + # and returns a REXML::Element with its content. Must respond to + # `load_sdf_raw(path: String) -> REXML::Element` # @return [REXML::Element,(REXML::Element,Hash)] either the XML tree by itself # if `metadata` is false, or the pair of the tree and the metadata hash # otherwise. @@ -498,8 +437,8 @@ def self.sdf_version_of(sdf) # @raise [NotSDF] if the file is not a SDF file # @raise [InvalidXML] if the file is not a valid XML file # @return [REXML::Element] - def self.load_sdf(sdf_file, flatten: true, metadata: false) - sdf = load_sdf_raw(sdf_file) + def self.load_sdf(sdf_file, flatten: true, metadata: false, loader: SDF::Loader.new) + sdf = loader.load_sdf_raw(sdf_file) sdf_version = sdf_version_of(sdf) sdf_metadata = Hash["includes" => {}, "path" => sdf_file] diff --git a/test/data/models/simple_model_erb/model.sdf.erb b/test/data/models/simple_model_erb/model.sdf.erb index 05ec12d..bf43744 100644 --- a/test/data/models/simple_model_erb/model.sdf.erb +++ b/test/data/models/simple_model_erb/model.sdf.erb @@ -3,8 +3,8 @@ default_gps_pose = [-0.679, 0.0, 1.920, 0.0, 0.0, 0.0] default_gps2_pose = [2.571, 0.044, 0.808, 0.0, 0.0, 0.0] - gps1_pose = (defined?(gps_sensors) && gps_sensors.dig(:gps, :pose)) || default_gps_pose - gps2_pose = (defined?(gps_sensors) && gps_sensors.dig(:gps2, :pose)) || default_gps2_pose + gps1_pose = (defined?(links) && links.find { |link| link[:name] == "gps" }&.dig(:pose)) || default_gps_pose + gps2_pose = (defined?(links) && links.find { |link| link[:name] == "gps2" }&.dig(:pose)) || default_gps2_pose %> @@ -35,5 +35,8 @@ gps2 + + + diff --git a/test/test_erb.rb b/test/test_erb.rb index 5dec053..f329802 100644 --- a/test/test_erb.rb +++ b/test/test_erb.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require "sdf/erb" +require "sdf/test" describe SDF::ERB do it "read_erb_file" do @@ -14,10 +14,11 @@ expected_sdf_str = <<~XML <% - default_gps_pose = [-0.679, 0.0, 1.920, 0.0, 0.0, 0.0] - default_gps2_pose = [2.571, 0.044, 0.808, 0.0, 0.0, 0.0] - gps1_pose = (defined?(gps_sensors) && gps_sensors.dig(:gps, :pose)) || default_gps_pose - gps2_pose = (defined?(gps_sensors) && gps_sensors.dig(:gps2, :pose)) || default_gps2_pose + default_gps_pose = [-0.679, 0.0, 1.920, 0.0, 0.0, 0.0] + default_gps2_pose = [2.571, 0.044, 0.808, 0.0, 0.0, 0.0] + + gps1_pose = (defined?(links) && links.find { |link| link[:name] == "gps" }&.dig(:pose)) || default_gps_pose + gps2_pose = (defined?(links) && links.find { |link| link[:name] == "gps2" }&.dig(:pose)) || default_gps2_pose %> @@ -47,6 +48,10 @@ root gps2 + + + + XML diff --git a/test/test_root.rb b/test/test_root.rb index a9da105..0853338 100644 --- a/test/test_root.rb +++ b/test/test_root.rb @@ -68,10 +68,11 @@ def regressions_dir end it "calls load_from_model_name if given a URI" do version = flexmock + loader = SDF::Loader.new flexmock(SDF::Root).should_receive(:load_from_model_name).once.with( - "model_in_uri", version, flatten: true + "model_in_uri", version, flatten: true, loader: loader ).and_return(obj = flexmock) - assert_equal obj, SDF::Root.load("model://model_in_uri", version) + assert_equal obj, SDF::Root.load("model://model_in_uri", version, loader: loader) end end diff --git a/test/test_sdf_loader.rb b/test/test_sdf_loader.rb new file mode 100644 index 0000000..9e64b80 --- /dev/null +++ b/test/test_sdf_loader.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +require "sdf/test" + +describe SDF::Loader do + describe "normalization and initialization" do + it "normalizes a plain String model name" do + loader = SDF::Loader.new("model://simple_model") + templates = loader.instance_variable_get(:@templates) + assert_equal 1, templates.size + assert_equal "model://simple_model", templates.first.model + end + + it "normalizes a single Hash representation passed as keywords directly" do + loader = SDF::Loader.new( + model: "model://direct_hash_model", + erb_args: { val: 42 } + ) + templates = loader.instance_variable_get(:@templates) + assert_equal 1, templates.size + assert_equal "model://direct_hash_model", templates.first.model + assert_equal({ val: 42 }, templates.first.erb_args) + end + + it "normalizes multiple hashes passed as separate arguments" do + loader = SDF::Loader.new( + { model: "model://model_a" }, + { model: "model://model_b" } + ) + templates = loader.instance_variable_get(:@templates) + assert_equal 2, templates.size + assert_equal "model://model_a", templates[0].model + assert_equal "model://model_b", templates[1].model + end + + it "normalizes a single ModelTemplate object directly" do + template_obj = SDF::Loader::ModelTemplate.new( + "model://template_obj" + ) + loader = SDF::Loader.new(template_obj) + templates = loader.instance_variable_get(:@templates) + assert_equal 1, templates.size + assert_equal "model://template_obj", templates.first.model + end + + it "normalizes an array of mixed types" do + template_obj = SDF::Loader::ModelTemplate.new("model://object_model") + loader = SDF::Loader.new([ + "model://string_model", + { model: "model://hash_model" }, + template_obj + ]) + templates = loader.instance_variable_get(:@templates) + assert_equal 3, templates.size + + assert_equal "model://string_model", templates[0].model + assert_equal "model://hash_model", templates[1].model + assert_equal "model://object_model", templates[2].model + end + + it "fails fast if a Hash is missing the required :model key" do + assert_raises(ArgumentError) do + SDF::Loader.new(erb_args: { key: "val" }) + end + end + + it "fails fast if passed an unsupported parameter type" do + assert_raises(ArgumentError) do + SDF::Loader.new(12_345) + end + end + + it "extracts the correct model name for bare strings" do + template = SDF::Loader::ModelTemplate.new("my_model") + assert_equal "my_model", template.model_name + end + + it "extracts the correct model name for local directory paths" do + template = SDF::Loader::ModelTemplate.new("/path/to/my_model") + assert_equal "my_model", template.model_name + end + + it "normalizes hashes with string keys" do + loader = SDF::Loader.new("model" => "model://string_hash_model", + "erb_args" => { val: 42 }) + templates = loader.instance_variable_get(:@templates) + assert_equal 1, templates.size + assert_equal "model://string_hash_model", templates.first.model + assert_equal({ val: 42 }, templates.first.erb_args) + end + + it "normalizes string keys inside erb_args to symbols" do + loader = SDF::Loader.new( + model: "model://simple_model_erb", + erb_args: { "links" => [] } + ) + templates = loader.instance_variable_get(:@templates) + assert_equal({ links: [] }, templates.first.erb_args) + end + end + + describe "#loads real file" do + it "loads a real .sdf.erb file with args" do + erb_args = { + links: [ + { + name: "gps", + pose: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0] + }, + { + name: "gps2", + pose: [6.0, 7.0, 8.0, 9.0, 0.0, 1.0] + } + ] + } + loader = SDF::Loader.new( + { + model: "model://simple_model_erb", + erb_args: erb_args + } + ) + + erb_file_path = File.expand_path( + "data/models/simple_model_erb/model.sdf.erb", __dir__ + ) + erb_content = loader.load_sdf_raw(erb_file_path) + + assert_equal "simple_model_erb", + REXML::XPath.first(erb_content, "//model").attributes["name"] + poses = REXML::XPath.match(erb_content, "//pose").map(&:text) + assert_includes poses, "0.0 1.0 2.0 3.0 4.0 5.0" + assert_includes poses, "6.0 7.0 8.0 9.0 0.0 1.0" + end + + it "loads a real .sdf.erb file without args" do + loader = SDF::Loader.new( + { + model: "model://simple_model_erb" + } + ) + + erb_file_path = File.expand_path( + "data/models/simple_model_erb/model.sdf.erb", __dir__ + ) + erb_content = loader.load_sdf_raw(erb_file_path) + + assert_equal "simple_model_erb", + REXML::XPath.first(erb_content, "//model").attributes["name"] + poses = REXML::XPath.match(erb_content, "//pose").map(&:text) + assert_includes poses, "-0.679 0.0 1.92 0.0 0.0 0.0" + assert_includes poses, "2.571 0.044 0.808 0.0 0.0 0.0" + end + + it "fallback from .sdf to .sdf.erb and loads file with args" do + erb_args = { + links: [ + { + name: "gps", + pose: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0] + }, + { + name: "gps2", + pose: [6.0, 7.0, 8.0, 9.0, 0.0, 1.0] + } + ] + } + loader = SDF::Loader.new( + { + model: "model://simple_model_erb", + erb_args: erb_args + } + ) + + erb_file_path = File.expand_path("data/models/simple_model_erb/model.sdf", + __dir__) + erb_content = loader.load_sdf_raw(erb_file_path) + + assert_equal "simple_model_erb", + REXML::XPath.first(erb_content, "//model").attributes["name"] + poses = REXML::XPath.match(erb_content, "//pose").map(&:text) + assert_includes poses, "0.0 1.0 2.0 3.0 4.0 5.0" + assert_includes poses, "6.0 7.0 8.0 9.0 0.0 1.0" + end + + it "fallback from .sdf to .sdf.erb and loads file without args" do + loader = SDF::Loader.new( + { + model: "model://simple_model_erb" + } + ) + + erb_file_path = File.expand_path("data/models/simple_model_erb/model.sdf", + __dir__) + erb_content = loader.load_sdf_raw(erb_file_path) + + assert_equal "simple_model_erb", + REXML::XPath.first(erb_content, "//model").attributes["name"] + poses = REXML::XPath.match(erb_content, "//pose").map(&:text) + assert_includes poses, "-0.679 0.0 1.92 0.0 0.0 0.0" + assert_includes poses, "2.571 0.044 0.808 0.0 0.0 0.0" + end + + it "fallback from .sdf to .sdf.erb and loads file without model and args" do + loader = SDF::Loader.new + + erb_file_path = File.expand_path("data/models/simple_model_erb/model.sdf", + __dir__) + erb_content = loader.load_sdf_raw(erb_file_path) + + assert_equal "simple_model_erb", + REXML::XPath.first(erb_content, "//model").attributes["name"] + poses = REXML::XPath.match(erb_content, "//pose").map(&:text) + assert_includes poses, "-0.679 0.0 1.92 0.0 0.0 0.0" + assert_includes poses, "2.571 0.044 0.808 0.0 0.0 0.0" + end + + it "loads a template with string-keyed erb_args successfully without crashing" do + loader = SDF::Loader.new( + model: "model://simple_model_erb", + erb_args: { "links" => [] } + ) + erb_file_path = File.expand_path( + "data/models/simple_model_erb/model.sdf.erb", __dir__ + ) + erb_content = loader.load_sdf_raw(erb_file_path) + assert_equal "simple_model_erb", + REXML::XPath.first(erb_content, "//model").attributes["name"] + end + + it "has a clean error message for missing .sdf.erb files" do + loader = SDF::Loader.new + err = assert_raises(Errno::ENOENT) do + loader.load_sdf_raw("/path/to/missing_file.sdf.erb") + end + refute_match(/\.sdf\.erb\.erb/, err.message) + end + end +end diff --git a/test/test_xml.rb b/test/test_xml.rb index dd37463..bb6e45e 100644 --- a/test/test_xml.rb +++ b/test/test_xml.rb @@ -401,19 +401,6 @@ def sdf_model_in_model_that_replaces_pose_in_include end end - describe "load_sdf with a ERB templated model" do - it "loads SDF file fallbacks to ERB when model.sdf doesn't exist" do - sdf = SDF::XML.load_sdf_raw(File.join(models_dir, "simple_model_erb", "model.sdf")) - model = sdf.elements.enum_for(:each, "sdf/model").first - assert_equal("simple_model_erb", model.attributes["name"]) - end - it "loads ERB templated file" do - sdf = SDF::XML.load_sdf_raw(File.join(models_dir, "simple_model_erb", "model.sdf.erb")) - model = sdf.elements.enum_for(:each, "sdf/model").first - assert_equal("simple_model_erb", model.attributes["name"]) - end - end - describe "model_from_name" do it "resolves and returns the raw model" do sdf = SDF::XML.model_from_name("simple_model") From d23b7fe28d8aacb5fdec50cbf666d46188b923cb Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 6 Aug 2026 18:57:09 -0300 Subject: [PATCH 7/7] SDF::Loader -> SDF::ERBLoader and new SDF::Loader for SDF only --- lib/sdf/erb.rb | 26 +- lib/sdf/erb_loader.rb | 18 ++ lib/sdf/sdf_loader.rb | 116 ++------- lib/sdf/xml.rb | 5 +- .../data/models/simple_model_erb/model.config | 2 +- test/test_erb.rb | 72 +----- test/test_erb_loader.rb | 69 +++++ test/test_sdf_loader.rb | 238 +----------------- 8 files changed, 121 insertions(+), 425 deletions(-) create mode 100644 lib/sdf/erb_loader.rb create mode 100644 test/test_erb_loader.rb diff --git a/lib/sdf/erb.rb b/lib/sdf/erb.rb index b235ee5..0e71d90 100644 --- a/lib/sdf/erb.rb +++ b/lib/sdf/erb.rb @@ -7,30 +7,6 @@ module SDF module ERB module_function - # Open an .erb file and returns its content as a string - # - # @param [String] file_path path to the .erb template file - # @return [String] erb file content as a string - # - # @raise [ArgumentError] if the file path is not a .erb file, - # is invalid, or is unreadable - def read_erb_file(file_path) - unless file_path.end_with?(".erb") - raise ArgumentError, - "Provided file path must have a '.erb' extension: #{file_path}" - end - - begin - erb_content = File.read(file_path) - rescue Errno::ENOENT - raise ArgumentError, "ERB template file not found at: #{file_path}" - rescue Errno::EACCES - raise ArgumentError, - "Permission denied reading ERB template at: #{file_path}" - end - erb_content - end - # Parses an ERB string and returns the raw rendered string # # @param [String] erb_content ERB template file content as string @@ -47,7 +23,7 @@ def parse_erb_as_str(erb_content, **erb_args) # # @return [REXML::Document] the rendered sdf model def render_erb_sdf_model(path, **erb_args) - erb_content = read_erb_file(path) + erb_content = File.read(path) solved_erb_as_sdf_str = parse_erb_as_str(erb_content, **erb_args) REXML::Document.new(solved_erb_as_sdf_str) diff --git a/lib/sdf/erb_loader.rb b/lib/sdf/erb_loader.rb new file mode 100644 index 0000000..77751b1 --- /dev/null +++ b/lib/sdf/erb_loader.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require_relative "erb" +require_relative "sdf_loader" + +module SDF + # class to load SDF and ERB templated SDF files + class ERBLoader < Loader + def initialize(erb_args: {}) + super() + @erb_args = erb_args + end + + def parse_sdf_document(sdf_file) + SDF::ERB.render_erb_sdf_model(sdf_file, **@erb_args) + end + end +end diff --git a/lib/sdf/sdf_loader.rb b/lib/sdf/sdf_loader.rb index 4ea6a72..1daf41f 100644 --- a/lib/sdf/sdf_loader.rb +++ b/lib/sdf/sdf_loader.rb @@ -6,79 +6,7 @@ module SDF # class to load SDF and ERB templated SDF files class Loader - # Represents the configuration for rendering a single ERB template. - class ModelTemplate - attr_reader :model, :erb_args - - def initialize( - model, - erb_args: {} - ) - @model = model - @erb_args = (erb_args || {}).transform_keys(&:to_sym) - end - - def self.from_hash(hash) - # Convert string keys to symbols to support string-keyed hashes - symbolized = hash.transform_keys(&:to_sym) - model = symbolized.fetch(:model) do - raise ArgumentError, - "The :model key is required when constructing from a Hash" - end - options = symbolized.reject { |k| k == :model } - new(model, **options) - end - - def model_name - if @model.start_with?("model://") - @model.sub("model://", "").split("/").first - elsif @model.end_with?(".sdf") || @model.end_with?(".sdf.erb") - File.basename(File.dirname(@model)) - else - File.basename(@model) - end - end - end - - # Initializes the loader with one or more templates. - # - # Supports: - # - ERBLoader.new(model: "...", erb_args: ...) - # - ERBLoader.new({ model: "a" }, { model: "b" }) - # - ERBLoader.new([{ model: "a" }, { model: "b" }]) - # - ERBLoader.new(ModelTemplate.new("a"), ModelTemplate.new("b")) - def initialize(*templates) - # Unwrap the outer array if multiple templates were passed - # inside an explicit Array - if templates.size == 1 && templates.first.kind_of?(Array) - templates = templates.first - end - @templates = templates.map do |template| - case template - when ModelTemplate - template - when Hash - ModelTemplate.from_hash(template) - when String - ModelTemplate.new(template) - else - raise ArgumentError, - "Expected SDF::Loader::ModelTemplate, Hash, or " \ - "String, got #{template.class}" - end - end - end - - def render_erb_sdf_model(sdf_file) - target_model_name = File.basename(File.dirname(sdf_file)) - model_config = @templates.find { |t| t.model_name == target_model_name } - erb_args = model_config&.erb_args || {} - SDF::ERB.render_erb_sdf_model(sdf_file, **erb_args) - end - - # Open a SDF file or ERB templated SDF file and returns the XML representation. - # - # The input files must have `.sdf` or `.sdf.erb` as its extension + # Open a SDF file SDF file and returns its XML representation. # # @param [String] sdf_file the path to the SDF file # @raise [Errno::ENOENT] if the files does not exist @@ -86,10 +14,18 @@ def render_erb_sdf_model(sdf_file) # @raise [InvalidXML] if the file is not a valid XML file # @return [REXML::Element] sdf_file's content as a REXML::Element instance def load_sdf_raw(sdf_file) - erb_file = sdf_file.end_with?(".sdf.erb") ? sdf_file : "#{sdf_file}.erb" - find_or_raise_file_not_found(sdf_file, erb_file) + find_or_raise_file_not_found(sdf_file) + + sdf = begin + parse_sdf_document(sdf_file) + rescue REXML::ParseException => e + unless e.message.include?("No root") + raise SDF::XML::InvalidXML, + "Cannot load #{sdf_file}: #{e.message}" + end - sdf = parse_sdf_document(sdf_file, erb_file) + REXML::Document.new + end validate_sdf_root(sdf, sdf_file) sdf @@ -97,35 +33,21 @@ def load_sdf_raw(sdf_file) private - def find_or_raise_file_not_found(sdf_file, erb_file) - return if File.exist?(sdf_file) || File.exist?(erb_file) + def find_or_raise_file_not_found(sdf_file) + return if File.exist?(sdf_file) file_name = File.basename(sdf_file) dir_path = File.dirname(sdf_file) - msg = if sdf_file.end_with?(".sdf.erb") - "Cannot find '#{file_name}' in '#{dir_path}'. " - else - "Cannot find '#{file_name}' or '#{file_name}.erb' in " \ - "'#{dir_path}'. " - end raise Errno::ENOENT, - "#{msg}You probably want to update the GAZEBO_MODEL_PATH " \ + "Cannot find '#{file_name}' in '#{dir_path}'." \ + "You probably want to update the GAZEBO_MODEL_PATH " \ "environment variable, or set SDF.model_path explicitly." end - def parse_sdf_document(sdf_file, erb_file) - if File.exist?(sdf_file) && !sdf_file.end_with?(".erb") - File.open(sdf_file) { |io| REXML::Document.new(io) } - else - render_erb_sdf_model(erb_file) + def parse_sdf_document(sdf_file) + File.open(sdf_file) do |io| + REXML::Document.new(io) end - rescue REXML::ParseException => e - unless e.message.include?("No root") - raise SDF::XML::InvalidXML, - "Cannot load #{sdf_file}: #{e.message}" - end - - REXML::Document.new end def validate_sdf_root(sdf, sdf_file) diff --git a/lib/sdf/xml.rb b/lib/sdf/xml.rb index 6505494..035f05e 100644 --- a/lib/sdf/xml.rb +++ b/lib/sdf/xml.rb @@ -1,6 +1,7 @@ require "rexml/document" require_relative "exceptions" require_relative "sdf_loader" +require_relative "erb_loader" module SDF module XML @@ -112,7 +113,7 @@ def self.model_path_of(dir, sdf_version = nil) # # @!macro sdf_version # @return [Hash] - def self.gazebo_models(sdf_version = nil, loader: SDF::Loader.new) + def self.gazebo_models(sdf_version = nil, loader: SDF::ERBLoader.new) @gazebo_models[sdf_version] ||= {} @model_path.each do |p| Dir.glob(File.join(p, "*")) do |subdir| @@ -442,7 +443,7 @@ def self.load_sdf(sdf_file, flatten: true, metadata: false, loader: SDF::Loader. sdf_version = sdf_version_of(sdf) sdf_metadata = Hash["includes" => {}, "path" => sdf_file] - includes = add_include_tags(sdf.root, sdf_version, File.dirname(sdf_file)) + includes = add_include_tags(sdf.root, sdf_version, File.dirname(sdf_file), loader: loader) sdf_metadata["includes"].merge!(includes) do |_, old, new| old + new end diff --git a/test/data/models/simple_model_erb/model.config b/test/data/models/simple_model_erb/model.config index d84a0c8..8751eb6 100644 --- a/test/data/models/simple_model_erb/model.config +++ b/test/data/models/simple_model_erb/model.config @@ -1,5 +1,5 @@ simple_model - model.sdf + model.sdf.erb diff --git a/test/test_erb.rb b/test/test_erb.rb index f329802..fbdad05 100644 --- a/test/test_erb.rb +++ b/test/test_erb.rb @@ -1,79 +1,9 @@ # frozen_string_literal: true +require "sdf/erb" require "sdf/test" describe SDF::ERB do - it "read_erb_file" do - template_path = File.expand_path("data/models/simple_model_erb/model.sdf.erb", - __dir__) - - sdf_str = SDF::ERB.read_erb_file(template_path) - - assert sdf_str - - expected_sdf_str = <<~XML - - <% - default_gps_pose = [-0.679, 0.0, 1.920, 0.0, 0.0, 0.0] - default_gps2_pose = [2.571, 0.044, 0.808, 0.0, 0.0, 0.0] - - gps1_pose = (defined?(links) && links.find { |link| link[:name] == "gps" }&.dig(:pose)) || default_gps_pose - gps2_pose = (defined?(links) && links.find { |link| link[:name] == "gps2" }&.dig(:pose)) || default_gps2_pose - %> - - - - - - - - root - child - - - - - - <%= gps1_pose.join(' ') %> - - - root - gps - - - - <%= gps2_pose.join(' ') %> - - - root - gps2 - - - - - - - - XML - - formatted_str = sdf_str.gsub(/\s+/, " ").strip - formatted_expected = expected_sdf_str.gsub(/\s+/, " ").strip - - assert_equal(formatted_expected, formatted_str) - end - - it "read_erb_file_wrong_extension" do - assert_raises(ArgumentError) do - SDF::ERB.read_erb_file("data/models/simple_model_erb/model.config") - end - end - - it "read_erb_file_do_not_exist" do - assert_raises(ArgumentError) do - SDF::ERB.read_erb_file("/tmp/i_do_not_exist_i_hope.erb") - end - end - it "parse_erb_as_str" do erb_content = <<~XML diff --git a/test/test_erb_loader.rb b/test/test_erb_loader.rb new file mode 100644 index 0000000..aef95f3 --- /dev/null +++ b/test/test_erb_loader.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "sdf/erb_loader" +require "sdf/test" + +describe SDF::ERBLoader do + describe "#loads real file" do + before(:all) do + @models_dir = File.expand_path("data/models", __dir__) + @simple_model = File.join(@models_dir, "/simple_model_erb/model.sdf.erb") + end + + it "loads a real .sdf.erb file with args" do + erb_args = { + links: [ + { + name: "gps", + pose: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0] + }, + { + name: "gps2", + pose: [6.0, 7.0, 8.0, 9.0, 0.0, 1.0] + } + ] + } + loader = SDF::ERBLoader.new(erb_args: erb_args) + + erb_content = loader.load_sdf_raw(@simple_model) + + assert_equal "simple_model_erb", + REXML::XPath.first(erb_content, "//model").attributes["name"] + poses = REXML::XPath.match(erb_content, "//pose").map(&:text) + assert_includes poses, "0.0 1.0 2.0 3.0 4.0 5.0" + assert_includes poses, "6.0 7.0 8.0 9.0 0.0 1.0" + end + + it "loads a real .sdf.erb file without args" do + erb_content = SDF::ERBLoader.new.load_sdf_raw(@simple_model) + + assert_equal "simple_model_erb", + REXML::XPath.first(erb_content, "//model").attributes["name"] + poses = REXML::XPath.match(erb_content, "//pose").map(&:text) + assert_includes poses, "-0.679 0.0 1.92 0.0 0.0 0.0" + assert_includes poses, "2.571 0.044 0.808 0.0 0.0 0.0" + end + + it "validates that the file is a XML file" do + assert_raises(SDF::XML::InvalidXML) do + SDF::ERBLoader.new.load_sdf_raw(File.join(@models_dir, "not_xml.xml")) + end + end + it "validates that the file has a root" do + assert_raises(SDF::XML::NotSDF) do + SDF::ERBLoader.new.load_sdf_raw(File.join(@models_dir, "no_root.xml")) + end + end + it "validates that the file is a SDF file" do + assert_raises(SDF::XML::NotSDF) do + SDF::ERBLoader.new.load_sdf_raw(File.join(@models_dir, "not_sdf.xml")) + end + end + it "validates that the file exists" do + assert_raises(Errno::ENOENT) do + SDF::ERBLoader.new.load_sdf_raw(File.join(@models_dir, + "does_not_exist.xml")) + end + end + end +end diff --git a/test/test_sdf_loader.rb b/test/test_sdf_loader.rb index 9e64b80..9ebef29 100644 --- a/test/test_sdf_loader.rb +++ b/test/test_sdf_loader.rb @@ -1,238 +1,18 @@ # frozen_string_literal: true +require "sdf/sdf_loader" require "sdf/test" describe SDF::Loader do - describe "normalization and initialization" do - it "normalizes a plain String model name" do - loader = SDF::Loader.new("model://simple_model") - templates = loader.instance_variable_get(:@templates) - assert_equal 1, templates.size - assert_equal "model://simple_model", templates.first.model - end + it "fallback from .sdf to .sdf.erb and loads file without model and args" do + loader = SDF::Loader.new - it "normalizes a single Hash representation passed as keywords directly" do - loader = SDF::Loader.new( - model: "model://direct_hash_model", - erb_args: { val: 42 } - ) - templates = loader.instance_variable_get(:@templates) - assert_equal 1, templates.size - assert_equal "model://direct_hash_model", templates.first.model - assert_equal({ val: 42 }, templates.first.erb_args) - end + sdf_file_path = File.expand_path( + "data/models/simple_model/model.sdf", __dir__ + ) + content = loader.load_sdf_raw(sdf_file_path) - it "normalizes multiple hashes passed as separate arguments" do - loader = SDF::Loader.new( - { model: "model://model_a" }, - { model: "model://model_b" } - ) - templates = loader.instance_variable_get(:@templates) - assert_equal 2, templates.size - assert_equal "model://model_a", templates[0].model - assert_equal "model://model_b", templates[1].model - end - - it "normalizes a single ModelTemplate object directly" do - template_obj = SDF::Loader::ModelTemplate.new( - "model://template_obj" - ) - loader = SDF::Loader.new(template_obj) - templates = loader.instance_variable_get(:@templates) - assert_equal 1, templates.size - assert_equal "model://template_obj", templates.first.model - end - - it "normalizes an array of mixed types" do - template_obj = SDF::Loader::ModelTemplate.new("model://object_model") - loader = SDF::Loader.new([ - "model://string_model", - { model: "model://hash_model" }, - template_obj - ]) - templates = loader.instance_variable_get(:@templates) - assert_equal 3, templates.size - - assert_equal "model://string_model", templates[0].model - assert_equal "model://hash_model", templates[1].model - assert_equal "model://object_model", templates[2].model - end - - it "fails fast if a Hash is missing the required :model key" do - assert_raises(ArgumentError) do - SDF::Loader.new(erb_args: { key: "val" }) - end - end - - it "fails fast if passed an unsupported parameter type" do - assert_raises(ArgumentError) do - SDF::Loader.new(12_345) - end - end - - it "extracts the correct model name for bare strings" do - template = SDF::Loader::ModelTemplate.new("my_model") - assert_equal "my_model", template.model_name - end - - it "extracts the correct model name for local directory paths" do - template = SDF::Loader::ModelTemplate.new("/path/to/my_model") - assert_equal "my_model", template.model_name - end - - it "normalizes hashes with string keys" do - loader = SDF::Loader.new("model" => "model://string_hash_model", - "erb_args" => { val: 42 }) - templates = loader.instance_variable_get(:@templates) - assert_equal 1, templates.size - assert_equal "model://string_hash_model", templates.first.model - assert_equal({ val: 42 }, templates.first.erb_args) - end - - it "normalizes string keys inside erb_args to symbols" do - loader = SDF::Loader.new( - model: "model://simple_model_erb", - erb_args: { "links" => [] } - ) - templates = loader.instance_variable_get(:@templates) - assert_equal({ links: [] }, templates.first.erb_args) - end - end - - describe "#loads real file" do - it "loads a real .sdf.erb file with args" do - erb_args = { - links: [ - { - name: "gps", - pose: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0] - }, - { - name: "gps2", - pose: [6.0, 7.0, 8.0, 9.0, 0.0, 1.0] - } - ] - } - loader = SDF::Loader.new( - { - model: "model://simple_model_erb", - erb_args: erb_args - } - ) - - erb_file_path = File.expand_path( - "data/models/simple_model_erb/model.sdf.erb", __dir__ - ) - erb_content = loader.load_sdf_raw(erb_file_path) - - assert_equal "simple_model_erb", - REXML::XPath.first(erb_content, "//model").attributes["name"] - poses = REXML::XPath.match(erb_content, "//pose").map(&:text) - assert_includes poses, "0.0 1.0 2.0 3.0 4.0 5.0" - assert_includes poses, "6.0 7.0 8.0 9.0 0.0 1.0" - end - - it "loads a real .sdf.erb file without args" do - loader = SDF::Loader.new( - { - model: "model://simple_model_erb" - } - ) - - erb_file_path = File.expand_path( - "data/models/simple_model_erb/model.sdf.erb", __dir__ - ) - erb_content = loader.load_sdf_raw(erb_file_path) - - assert_equal "simple_model_erb", - REXML::XPath.first(erb_content, "//model").attributes["name"] - poses = REXML::XPath.match(erb_content, "//pose").map(&:text) - assert_includes poses, "-0.679 0.0 1.92 0.0 0.0 0.0" - assert_includes poses, "2.571 0.044 0.808 0.0 0.0 0.0" - end - - it "fallback from .sdf to .sdf.erb and loads file with args" do - erb_args = { - links: [ - { - name: "gps", - pose: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0] - }, - { - name: "gps2", - pose: [6.0, 7.0, 8.0, 9.0, 0.0, 1.0] - } - ] - } - loader = SDF::Loader.new( - { - model: "model://simple_model_erb", - erb_args: erb_args - } - ) - - erb_file_path = File.expand_path("data/models/simple_model_erb/model.sdf", - __dir__) - erb_content = loader.load_sdf_raw(erb_file_path) - - assert_equal "simple_model_erb", - REXML::XPath.first(erb_content, "//model").attributes["name"] - poses = REXML::XPath.match(erb_content, "//pose").map(&:text) - assert_includes poses, "0.0 1.0 2.0 3.0 4.0 5.0" - assert_includes poses, "6.0 7.0 8.0 9.0 0.0 1.0" - end - - it "fallback from .sdf to .sdf.erb and loads file without args" do - loader = SDF::Loader.new( - { - model: "model://simple_model_erb" - } - ) - - erb_file_path = File.expand_path("data/models/simple_model_erb/model.sdf", - __dir__) - erb_content = loader.load_sdf_raw(erb_file_path) - - assert_equal "simple_model_erb", - REXML::XPath.first(erb_content, "//model").attributes["name"] - poses = REXML::XPath.match(erb_content, "//pose").map(&:text) - assert_includes poses, "-0.679 0.0 1.92 0.0 0.0 0.0" - assert_includes poses, "2.571 0.044 0.808 0.0 0.0 0.0" - end - - it "fallback from .sdf to .sdf.erb and loads file without model and args" do - loader = SDF::Loader.new - - erb_file_path = File.expand_path("data/models/simple_model_erb/model.sdf", - __dir__) - erb_content = loader.load_sdf_raw(erb_file_path) - - assert_equal "simple_model_erb", - REXML::XPath.first(erb_content, "//model").attributes["name"] - poses = REXML::XPath.match(erb_content, "//pose").map(&:text) - assert_includes poses, "-0.679 0.0 1.92 0.0 0.0 0.0" - assert_includes poses, "2.571 0.044 0.808 0.0 0.0 0.0" - end - - it "loads a template with string-keyed erb_args successfully without crashing" do - loader = SDF::Loader.new( - model: "model://simple_model_erb", - erb_args: { "links" => [] } - ) - erb_file_path = File.expand_path( - "data/models/simple_model_erb/model.sdf.erb", __dir__ - ) - erb_content = loader.load_sdf_raw(erb_file_path) - assert_equal "simple_model_erb", - REXML::XPath.first(erb_content, "//model").attributes["name"] - end - - it "has a clean error message for missing .sdf.erb files" do - loader = SDF::Loader.new - err = assert_raises(Errno::ENOENT) do - loader.load_sdf_raw("/path/to/missing_file.sdf.erb") - end - refute_match(/\.sdf\.erb\.erb/, err.message) - end + assert_equal "simple test model", + REXML::XPath.first(content, "//model").attributes["name"] end end