lib/openstudio/extension/runner.rb in openstudio-extension-0.1.2 vs lib/openstudio/extension/runner.rb in openstudio-extension-0.1.3

- old
+ new

@@ -1,644 +1,667 @@ - -# ******************************************************************************* -# OpenStudio(R), Copyright (c) 2008-2019, Alliance for Sustainable Energy, LLC. -# All rights reserved. -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# (1) Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# -# (2) Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# (3) Neither the name of the copyright holder nor the names of any contributors -# may be used to endorse or promote products derived from this software without -# specific prior written permission from the respective party. -# -# (4) Other than as required in clauses (1) and (2), distributions in any form -# of modifications or other derivative works may not use the "OpenStudio" -# trademark, "OS", "os", or any other confusingly similar designation without -# specific prior written permission from Alliance for Sustainable Energy, LLC. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER(S) AND ANY CONTRIBUTORS -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, -# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER(S), ANY CONTRIBUTORS, THE -# UNITED STATES GOVERNMENT, OR THE UNITED STATES DEPARTMENT OF ENERGY, NOR ANY OF -# THEIR EMPLOYEES, BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT -# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, -# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY -# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# ******************************************************************************* - -require 'bundler' -require 'fileutils' -require 'json' -require 'open3' -require 'openstudio' -require 'yaml' -require 'fileutils' -require 'parallel' - -module OpenStudio - module Extension - ## - # The Runner class provides functionality to run various commands including calls to the OpenStudio CLI. - # - class Runner - attr_reader :gemfile_path, :bundle_install_path - ## - # When initialized with a directory containing a Gemfile, the Runner will attempt to create a bundle - # compatible with the OpenStudio CLI. - ## - # @param [String] dirname Directory to run commands in, defaults to Dir.pwd. If directory includes a Gemfile then create a local bundle. - def initialize(dirname = Dir.pwd, bundle_without = []) - # DLM: I am not sure if we want to use the main root directory to create these bundles - # had the idea of passing in a Gemfile name/alias and path to Gemfile, then doing the bundle in ~/OpenStudio/#{alias} or something like that? - - puts "Initializing runner with dirname: '#{dirname}'" - @dirname = File.absolute_path(dirname) - @gemfile_path = File.join(@dirname, 'Gemfile') - @bundle_install_path = File.join(@dirname, '.bundle/install/') - @original_dir = Dir.pwd - - @bundle_without = bundle_without - @bundle_without_string = bundle_without.join(' ') - puts "@bundle_without_string = '#{@bundle_without_string}'" - - raise "#{@dirname} does not exist" if !File.exist?(@dirname) - raise "#{@dirname} is not a directory" if !File.directory?(@dirname) - - if !File.exist?(@gemfile_path) - # if there is no gemfile set these to nil - @gemfile_path = nil - @bundle_install_path = nil - else - # there is a gemfile, attempt to create a bundle - original_dir = Dir.pwd - begin - # go to the directory with the gemfile - Dir.chdir(@dirname) - - # test to see if bundle is installed - check_bundle = run_command('bundle -v', get_clean_env) - if !check_bundle - raise "Failed to run command 'bundle -v', check that bundle is installed" if !File.exist?(@dirname) - end - - # TODO: check that ruby version is correct - - # check existing config - needs_config = true - if File.exist?('./.bundle/config') - puts 'config exists' - needs_config = false - config = YAML.load_file('./.bundle/config') - - if config['BUNDLE_PATH'] != @bundle_install_path - needs_config = true - end - - # if config['BUNDLE_WITHOUT'] != @bundle_without_string - # needs_config = true - # end - end - - # check existing platform - needs_platform = true - if File.exist?('Gemfile.lock') - puts 'Gemfile.lock exists' - gemfile_lock = Bundler::LockfileParser.new(Bundler.read_file('Gemfile.lock')) - if gemfile_lock.platforms.include?('ruby') - # already been configured, might not be up to date - needs_platform = false - end - end - - puts "needs_config = #{needs_config}" - if needs_config - run_command("bundle config --local path '#{@bundle_install_path}'", get_clean_env) - # run_command("bundle config --local without '#{@bundle_without_string}'", get_clean_env) - end - - puts "needs_platform = #{needs_platform}" - if needs_platform - run_command('bundle lock --add_platform ruby', get_clean_env) - end - - needs_update = needs_config || needs_platform - if !needs_update - if !File.exist?('Gemfile.lock') || File.mtime(@gemfile_path) > File.mtime('Gemfile.lock') - needs_update = true - end - end - - puts "needs_update = #{needs_update}" - if needs_update - run_command('bundle update', get_clean_env) - end - ensure - Dir.chdir(@original_dir) - end - end - end - - ## - # Returns a hash of environment variables that can be merged with the current environment to prevent automatic bundle activation. - # - # DLM: should this be a module or class method? - ## - # @return [Hash] - def get_clean_env - # blank out bundler and gem path modifications, will be re-setup by new call - new_env = {} - new_env['BUNDLER_ORIG_MANPATH'] = nil - new_env['BUNDLER_ORIG_PATH'] = nil - new_env['BUNDLER_VERSION'] = nil - new_env['BUNDLE_BIN_PATH'] = nil - new_env['RUBYLIB'] = nil - new_env['RUBYOPT'] = nil - - # DLM: preserve GEM_HOME and GEM_PATH set by current bundle because we are not supporting bundle - # requires to ruby gems will work, will fail if we require a native gem - new_env['GEM_PATH'] = nil - new_env['GEM_HOME'] = nil - - # DLM: for now, ignore current bundle in case it has binary dependencies in it - # bundle_gemfile = ENV['BUNDLE_GEMFILE'] - # bundle_path = ENV['BUNDLE_PATH'] - # if bundle_gemfile.nil? || bundle_path.nil? - new_env['BUNDLE_GEMFILE'] = nil - new_env['BUNDLE_PATH'] = nil - new_env['BUNDLE_WITHOUT'] = nil - # else - # new_env['BUNDLE_GEMFILE'] = bundle_gemfile - # new_env['BUNDLE_PATH'] = bundle_path - # end - - return new_env - end - - ## - # Run a command after merging the current environment with env. Command is run in @dirname, returns to Dir.pwd after completion. - # Returns true if the command completes successfully, false otherwise. - # Standard Out, Standard Error, and Status Code are collected and printed, but not returned. - ## - # @return [Boolean] - def run_command(command, env = {}) - result = false - original_dir = Dir.pwd - begin - Dir.chdir(@dirname) - - # DLM: using popen3 here can result in deadlocks - stdout_str, stderr_str, status = Open3.capture3(env, command) - if status.success? - # puts "Command completed successfully" - # puts "stdout: #{stdout_str}" - # puts "stderr: #{stderr_str}" - # STDOUT.flush - result = true - else - puts "Error running command: '#{command}'" - puts "stdout: #{stdout_str}" - puts "stderr: #{stderr_str}" - STDOUT.flush - result = false - end - ensure - Dir.chdir(original_dir) - return result - end - - return result - end - - ## - # Get path to all measures found under measure dir. - ## - # @param [String] measures_dir Measures directory - ## - # @return [Array] returns path to all measure directories found under measure dir - def get_measures_in_dir(measures_dir) - measures = Dir.glob(File.join(measures_dir, '**/measure.rb')) - if measures.empty? - # also try nested 2-deep to support openstudio-measures - measures = Dir.glob(File.join(measures_dir, '**/**/measure.rb')) - end - - result = [] - measures.each { |m| result << File.dirname(m) } - return result - end - - ## - # Get path to all measures dirs found under measure dir. - ## - # @param [String] measures_dir Measures directory - ## - # @return [Array] returns path to all directories containing measures found under measure dir - def get_measure_dirs_in_dir(measures_dir) - measures = Dir.glob(File.join(measures_dir, '**/measure.rb')) - if measures.empty? - # also try nested 2-deep to support openstudio-measures - measures = Dir.glob(File.join(measures_dir, '**/**/measure.rb')) - end - - result = [] - measures.each { |m| result << File.dirname(File.dirname(m)) } - - return result.uniq - end - - ## - # Run the OpenStudio CLI command to test measures on given directory - # Returns true if the command completes successfully, false otherwise. - # measures_dir configured in rake_task - ## - # @return [Boolean] - def test_measures_with_cli(measures_dir) - puts 'Testing measures with CLI system call' - if measures_dir.nil? || measures_dir.empty? - puts 'Measures dir is nil or empty' - return true - end - - puts "measures path: #{measures_dir}" - - cli = OpenStudio.getOpenStudioCLI - - the_call = '' - if @gemfile_path - if @bundle_without_string.empty? - the_call = "#{cli} --verbose --bundle '#{@gemfile_path}' --bundle_path '#{@bundle_install_path}' measure -r '#{measures_dir}'" - else - the_call = "#{cli} --verbose --bundle '#{@gemfile_path}' --bundle_path '#{@bundle_install_path}' --bundle_without '#{@bundle_without_string}' measure -r '#{measures_dir}'" - end - else - the_call = "#{cli} --verbose measure -r #{measures_dir}" - end - - puts 'SYSTEM CALL:' - puts the_call - STDOUT.flush - result = run_command(the_call, get_clean_env) - puts "DONE, result = #{result}" - STDOUT.flush - - return result - end - - ## - # Run the OpenStudio CLI command to update measures on given directory - # Returns true if the command completes successfully, false otherwise. - ## - # @return [Boolean] - def update_measures(measures_dir) - puts 'Updating measures with CLI system call' - if measures_dir.nil? || measures_dir.empty? - puts 'Measures dir is nil or empty' - return true - end - - result = true - # DLM: this is a temporary workaround to handle OpenStudio-Measures - get_measure_dirs_in_dir(measures_dir).each do |measures_dir| - puts "measures path: #{measures_dir}" - - cli = OpenStudio.getOpenStudioCLI - - the_call = '' - if @gemfile_path - if @bundle_without_string.empty? - the_call = "#{cli} --verbose --bundle '#{@gemfile_path}' --bundle_path '#{@bundle_install_path}' measure -t '#{measures_dir}'" - else - the_call = "#{cli} --verbose --bundle '#{@gemfile_path}' --bundle_path '#{@bundle_install_path}' --bundle_without '#{@bundle_without_string}' measure -t '#{measures_dir}'" - end - else - the_call = "#{cli} --verbose measure -t '#{measures_dir}'" - end - - puts 'SYSTEM CALL:' - puts the_call - STDOUT.flush - result &&= run_command(the_call, get_clean_env) - puts "DONE, result = #{result}" - STDOUT.flush - end - - return result - end - - ## - # List measures in given directory - # Returns true if the command completes successfully, false otherwise. - ## - # @return [Boolean] - - ## - def list_measures(measures_dir) - puts 'Listing measures' - if measures_dir.nil? || measures_dir.empty? - puts 'Measures dir is nil or empty' - return true - end - - puts "measures path: #{measures_dir}" - - # this is to accommodate a single measures dir (like most gems) - # or a repo with multiple directories fo measures (like OpenStudio-measures) - measures = Dir.glob(File.join(measures_dir, '**/measure.rb')) - if measures.empty? - # also try nested 2-deep to support openstudio-measures - measures = Dir.glob(File.join(measures_dir, '**/**/measure.rb')) - end - puts "#{measures.length} MEASURES FOUND" - measures.each do |measure| - name = measure.split('/')[-2] - puts name.to_s - end - end - - # Update measures by copying in the latest resource files from the Extension gem into - # the measures' respective resources folders. - # measures_dir and core_dir configured in rake_task - # Returns true if the command completes successfully, false otherwise. - # - # @return [Boolean] - def copy_core_files(measures_dir, core_dir) - puts 'Copying measure resources' - if measures_dir.nil? || measures_dir.empty? - puts 'Measures dir is nil or empty' - return true - end - - result = false - puts 'Copying updated resource files from extension core directory to individual measures.' - puts 'Only files that have actually been changed will be listed.' - - # get all resource files in the core dir - resource_files = Dir.glob(File.join(core_dir, '/*.*')) - - # this is to accommodate a single measures dir (like most gems) - # or a repo with multiple directories fo measures (like OpenStudio-measures) - measures = Dir.glob(File.join(measures_dir, '**/resources/*.rb')) - if measures.empty? - # also try nested 2-deep to support openstudio-measures - measures = Dir.glob(File.join(measures_dir, '**/**/resources/*.rb')) - end - - # Note: some older measures like AEDG use 'OsLib_SomeName' instead of 'os_lib_some_name' - # this script isn't replacing those copies - - # loop through resource files - resource_files.each do |resource_file| - # loop through measure dirs looking for matching file - measures.each do |measure| - next unless File.basename(measure) == File.basename(resource_file) - next if FileUtils.identical?(resource_file, File.path(measure)) - puts "Replacing #{measure} with #{resource_file}." - FileUtils.cp(resource_file, File.path(measure)) - end - end - result = true - - return result - end - - # Update measures by adding license file - # measures_dir and doc_templates_dir configured in rake_task - # Returns true if the command completes successfully, false otherwise. - ## - # @return [Boolean] - def add_measure_license(measures_dir, doc_templates_dir) - puts 'Adding measure licenses' - if measures_dir.nil? || measures_dir.empty? - puts 'Measures dir is nil or empty' - return true - elsif doc_templates_dir.nil? || doc_templates_dir.empty? - puts 'Doc templates dir is nil or empty' - return false - end - - result = false - license_file = File.join(doc_templates_dir, 'LICENSE.md') - puts "License file path: #{license_file}" - - raise "License file not found '#{license_file}'" if !File.exist?(license_file) - - measures = Dir["#{measures_dir}/**/measure.rb"] - if measures.empty? - # also try nested 2-deep to support openstudio-measures - measures = Dir["#{measures_dir}/**/**/measure.rb"] - end - measures.each do |measure| - FileUtils.cp(license_file, "#{File.dirname(measure)}/LICENSE.md") - end - result = true - return result - end - - # Update measures by adding license file - # measures_dir and doc_templates_dir configured in rake_task - # Returns true if the command completes successfully, false otherwise. - ## - # @return [Boolean] - def add_measure_readme(measures_dir, doc_templates_dir) - puts 'Adding measure readmes' - if measures_dir.nil? || measures_dir.empty? - puts 'Measures dir is nil or empty' - return true - elsif doc_templates_dir.nil? || doc_templates_dir.empty? - puts 'Measures files dir is nil or empty' - return false - end - - result = false - readme_file = File.join(doc_templates_dir, 'README.md.erb') - puts "Readme file path: #{readme_file}" - - raise "Readme file not found '#{readme_file}'" if !File.exist?(readme_file) - - measures = Dir["#{measures_dir}/**/measure.rb"] - if measures.empty? - # also try nested 2-deep to support openstudio-measures - measures = Dir["#{measures_dir}/**/**/measure.rb"] - end - measures.each do |measure| - next if File.exist?("#{File.dirname(measure)}/README.md.erb") - next if File.exist?("#{File.dirname(measure)}/README.md") - puts "adding template README to #{measure}" - FileUtils.cp(readme_file, "#{File.dirname(measure)}/README.md.erb") - end - result = true - return result - end - - def update_copyright(root_dir, doc_templates_dir) - if root_dir.nil? || root_dir.empty? - puts 'Root dir is nil or empty' - return false - elsif doc_templates_dir.nil? || doc_templates_dir.empty? - puts 'Doc templates dir is nil or empty' - return false - end - - if File.exist?(File.join(doc_templates_dir, 'LICENSE.md')) - if File.exist?(File.join(root_dir, 'LICENSE.md')) - puts 'updating LICENSE.md in root dir' - FileUtils.cp(File.join(doc_templates_dir, 'LICENSE.md'), File.join(root_dir, 'LICENSE.md')) - end - end - - ruby_regex = /^\#\s?[\#\*]{12,}.*copyright.*?\#\s?[\#\*]{12,}\s*$/mi - erb_regex = /^<%\s*\#\s?[\#\*]{12,}.*copyright.*?\#\s?[\#\*]{12,}\s*%>$/mi - js_regex = /^\/\* @preserve.*copyright.*license.{2}\*\//mi - - filename = File.join(doc_templates_dir, 'copyright_ruby.txt') - puts "Copyright file path: #{filename}" - raise "Copyright file not found '#{filename}'" if !File.exist?(filename) - file = File.open(filename, 'r') - ruby_header_text = file.read - file.close - ruby_header_text.strip! - ruby_header_text += "\n" - - filename = File.join(doc_templates_dir, 'copyright_erb.txt') - puts "Copyright file path: #{filename}" - raise "Copyright file not found '#{filename}'" if !File.exist?(filename) - file = File.open(filename, 'r') - erb_header_text = file.read - file.close - erb_header_text.strip! - erb_header_text += "\n" - - filename = File.join(doc_templates_dir, 'copyright_js.txt') - puts "Copyright file path: #{filename}" - raise "Copyright file not found '#{filename}'" if !File.exist?(filename) - file = File.open(filename, 'r') - js_header_text = file.read - file.close - js_header_text.strip! - js_header_text += "\n" - - raise 'bad copyright_ruby.txt' if ruby_header_text !~ ruby_regex - raise 'bad copyright_erb.txt' if erb_header_text !~ erb_regex - raise 'bad copyright_js.txt' if js_header_text !~ js_regex - - # look for .rb, .html.erb, and .js.erb - paths = [ - { glob: "#{root_dir}/**/*.rb", license: ruby_header_text, regex: ruby_regex }, - { glob: "#{root_dir}/**/*.html.erb", license: erb_header_text, regex: erb_regex }, - { glob: "#{root_dir}/**/*.js.erb", license: js_header_text, regex: js_regex } - ] - - puts "Encoding.default_external = #{Encoding.default_external}" - puts "Encoding.default_internal = #{Encoding.default_internal}" - - paths.each do |path| - Dir[path[:glob]].each do |file| - puts "Updating license in file #{file}" - f = File.read(file) - if f =~ path[:regex] - puts ' License found -- updating' - File.open(file, 'w') { |write| write << f.gsub(path[:regex], path[:license]) } - elsif f =~ /\(C\)/i || f =~ /\(Copyright\)/i - puts ' File already has copyright -- skipping' - else - puts ' No license found -- adding' - if f =~ /#!/ - puts ' CANNOT add license to file automatically, add it manually and it will update automatically in the future' - next - end - File.open(file, 'w') { |write| write << f.insert(0, path[:license] + "\n") } - end - end - end - end - - ## - # Run the OpenStudio CLI on an OSW. The OSW is configured to include measure and file locations for all loaded OpenStudio Extensions. - ## - # @param [String, Hash] in_osw If string this is the path to an OSW file on disk, if Hash it is loaded JSON with symbolized keys - # @param [String] run_dir Directory to run the OSW in, will be created if does not exist - ## - # @return [Boolean] True if command succeeded, false otherwise # DLM: should this return path to out.osw instead? - def run_osw(in_osw, run_dir) - run_dir = File.absolute_path(run_dir) - - if in_osw.is_a?(String) - in_osw_path = in_osw - raise "'#{in_osw_path}' does not exist" if !File.exist?(in_osw_path) - - in_osw = {} - File.open(in_osw_path, 'r') do |file| - in_osw = JSON.parse(file.read, symbolize_names: true) - end - end - - osw = OpenStudio::Extension.configure_osw(in_osw) - osw[:run_directory] = run_dir - - FileUtils.mkdir_p(run_dir) - - run_osw_path = File.join(run_dir, 'in.osw') - File.open(run_osw_path, 'w') do |file| - file.puts JSON.pretty_generate(osw) - end - - cli = OpenStudio.getOpenStudioCLI - out_log = run_osw_path + '.log' - if Gem.win_platform? - # out_log = "nul" - else - # out_log = "/dev/null" - end - - the_call = '' - if @gemfile_path - if @bundle_without_string.empty? - the_call = "#{cli} --verbose --bundle '#{@gemfile_path}' --bundle_path '#{@bundle_install_path}' run -w '#{run_osw_path}' 2>&1 > \"#{out_log}\"" - else - the_call = "#{cli} --verbose --bundle '#{@gemfile_path}' --bundle_path '#{@bundle_install_path}' --bundle_without '#{@bundle_without_string}' run -w '#{run_osw_path}' 2>&1 > \"#{out_log}\"" - end - else - the_call = "#{cli} --verbose run -w '#{run_osw_path}' 2>&1 > \"#{out_log}\"" - end - - puts 'SYSTEM CALL:' - puts the_call - STDOUT.flush - result = run_command(the_call, get_clean_env) - puts "DONE, result = #{result}" - STDOUT.flush - - # DLM: this does not always return false for failed CLI runs, consider checking for failed.job file as backup test - - return result - end - - # run osws, return any failure messages - def run_osws(osw_files, num_parallel = 1, max_to_run = Float::INFINITY) - failures = [] - - osw_files = osw_files.slice(0, [osw_files.size, max_to_run].min) - - Parallel.each(osw_files, in_threads: num_parallel) do |osw| - # osw_files.each do |osw| - - result = run_osw(osw, File.dirname(osw)) - - if !result - failures << "Failed to run OSW '#{osw}'" - end - end - - return failures - end - end - end -end +# ******************************************************************************* +# OpenStudio(R), Copyright (c) 2008-2019, Alliance for Sustainable Energy, LLC. +# All rights reserved. +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# (1) Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# (2) Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# (3) Neither the name of the copyright holder nor the names of any contributors +# may be used to endorse or promote products derived from this software without +# specific prior written permission from the respective party. +# +# (4) Other than as required in clauses (1) and (2), distributions in any form +# of modifications or other derivative works may not use the "OpenStudio" +# trademark, "OS", "os", or any other confusingly similar designation without +# specific prior written permission from Alliance for Sustainable Energy, LLC. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER(S) AND ANY CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER(S), ANY CONTRIBUTORS, THE +# UNITED STATES GOVERNMENT, OR THE UNITED STATES DEPARTMENT OF ENERGY, NOR ANY OF +# THEIR EMPLOYEES, BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# ******************************************************************************* + +require 'bundler' +require 'fileutils' +require 'json' +require 'open3' +require 'openstudio' +require 'yaml' +require 'fileutils' +require 'parallel' + +module OpenStudio + module Extension + ## + # The Runner class provides functionality to run various commands including calls to the OpenStudio CLI. + # + class Runner + attr_reader :gemfile_path, :bundle_install_path, :options + + ## + # When initialized with a directory containing a Gemfile, the Runner will attempt to create a bundle + # compatible with the OpenStudio CLI. + ## + # @param [String] dirname Directory to run commands in, defaults to Dir.pwd. If directory includes a Gemfile then create a local bundle. + # @param bundle_without [Hash] Hash describing the distribution of the variable. + # @param options [Hash] Hash describing options for running the simulation. These are the defaults for all runs unless overriden within the run_* methods. + # @option options [String] :max_datapoints Max number of datapoints to run + # @option options [String] :num_parallel Number of simulations to run in parallel at a time + # @option options [String] :run_simulations Set to true to run the simulations + # @option options [String] :verbose Set to true to receive extra information while running simulations + def initialize(dirname = Dir.pwd, bundle_without = [], options = {}) + # DLM: I am not sure if we want to use the main root directory to create these bundles + # had the idea of passing in a Gemfile name/alias and path to Gemfile, then doing the bundle in ~/OpenStudio/#{alias} or something like that? + + # if the dirname contains a runner.conf file, then use the config file to specify the parameters + if File.exist?(File.join(dirname, OpenStudio::Extension::RunnerConfig::FILENAME)) && !options + puts 'Using runner options from runner.conf file' + runner_config = OpenStudio::Extension::RunnerConfig.new(dirname) + @options = runner_config.options + else + # use the passed values or defaults overriden by passed options + @options = OpenStudio::Extension::RunnerConfig.default_config.merge(options) + end + + puts "Initializing runner with dirname: '#{dirname}' and options: #{@options}" + @dirname = File.absolute_path(dirname) + @gemfile_path = File.join(@dirname, 'Gemfile') + @bundle_install_path = File.join(@dirname, '.bundle/install/') + @original_dir = Dir.pwd + + @bundle_without = bundle_without || [] + @bundle_without_string = @bundle_without.join(' ') + puts "@bundle_without_string = '#{@bundle_without_string}'" + + raise "#{@dirname} does not exist" unless File.exist?(@dirname) + raise "#{@dirname} is not a directory" unless File.directory?(@dirname) + + if !File.exist?(@gemfile_path) + # if there is no gemfile set these to nil + @gemfile_path = nil + @bundle_install_path = nil + else + # there is a gemfile, attempt to create a bundle + begin + # go to the directory with the gemfile + Dir.chdir(@dirname) + + # test to see if bundle is installed + check_bundle = run_command('bundle -v', get_clean_env) + if !check_bundle + raise "Failed to run command 'bundle -v', check that bundle is installed" if !File.exist?(@dirname) + end + + # TODO: check that ruby version is correct + + # check existing config + needs_config = true + if File.exist?('./.bundle/config') + puts 'config exists' + needs_config = false + config = YAML.load_file('./.bundle/config') + + if config['BUNDLE_PATH'] != @bundle_install_path + needs_config = true + end + + # if config['BUNDLE_WITHOUT'] != @bundle_without_string + # needs_config = true + # end + end + + # check existing platform + needs_platform = true + if File.exist?('Gemfile.lock') + puts 'Gemfile.lock exists' + gemfile_lock = Bundler::LockfileParser.new(Bundler.read_file('Gemfile.lock')) + if gemfile_lock.platforms.include?('ruby') + # already been configured, might not be up to date + needs_platform = false + end + end + + puts "needs_config = #{needs_config}" + if needs_config + run_command("bundle config --local path '#{@bundle_install_path}'", get_clean_env) + # run_command("bundle config --local without '#{@bundle_without_string}'", get_clean_env) + end + + puts "needs_platform = #{needs_platform}" + if needs_platform + run_command('bundle lock --add_platform ruby', get_clean_env) + end + + needs_update = needs_config || needs_platform + if !needs_update + if !File.exist?('Gemfile.lock') || File.mtime(@gemfile_path) > File.mtime('Gemfile.lock') + needs_update = true + end + end + + puts "needs_update = #{needs_update}" + if needs_update + run_command('bundle update', get_clean_env) + end + ensure + Dir.chdir(@original_dir) + end + end + end + + ## + # Returns a hash of environment variables that can be merged with the current environment to prevent automatic bundle activation. + # + # DLM: should this be a module or class method? + ## + # @return [Hash] + def get_clean_env + # blank out bundler and gem path modifications, will be re-setup by new call + new_env = {} + new_env['BUNDLER_ORIG_MANPATH'] = nil + new_env['BUNDLER_ORIG_PATH'] = nil + new_env['BUNDLER_VERSION'] = nil + new_env['BUNDLE_BIN_PATH'] = nil + new_env['RUBYLIB'] = nil + new_env['RUBYOPT'] = nil + + # DLM: preserve GEM_HOME and GEM_PATH set by current bundle because we are not supporting bundle + # requires to ruby gems will work, will fail if we require a native gem + new_env['GEM_PATH'] = nil + new_env['GEM_HOME'] = nil + + # DLM: for now, ignore current bundle in case it has binary dependencies in it + # bundle_gemfile = ENV['BUNDLE_GEMFILE'] + # bundle_path = ENV['BUNDLE_PATH'] + # if bundle_gemfile.nil? || bundle_path.nil? + new_env['BUNDLE_GEMFILE'] = nil + new_env['BUNDLE_PATH'] = nil + new_env['BUNDLE_WITHOUT'] = nil + # else + # new_env['BUNDLE_GEMFILE'] = bundle_gemfile + # new_env['BUNDLE_PATH'] = bundle_path + # end + + return new_env + end + + ## + # Run a command after merging the current environment with env. Command is run in @dirname, returns to Dir.pwd after completion. + # Returns true if the command completes successfully, false otherwise. + # Standard Out, Standard Error, and Status Code are collected and printed, but not returned. + ## + # @return [Boolean] + def run_command(command, env = {}) + result = false + original_dir = Dir.pwd + begin + Dir.chdir(@dirname) + + # DLM: using popen3 here can result in deadlocks + stdout_str, stderr_str, status = Open3.capture3(env, command) + if status.success? + # puts "Command completed successfully" + # puts "stdout: #{stdout_str}" + # puts "stderr: #{stderr_str}" + # STDOUT.flush + result = true + else + puts "Error running command: '#{command}'" + puts "stdout: #{stdout_str}" + puts "stderr: #{stderr_str}" + STDOUT.flush + result = false + end + ensure + Dir.chdir(original_dir) + end + + return result + end + + ## + # Get path to all measures found under measure dir. + ## + # @param [String] measures_dir Measures directory + ## + # @return [Array] returns path to all measure directories found under measure dir + def get_measures_in_dir(measures_dir) + measures = Dir.glob(File.join(measures_dir, '**/measure.rb')) + if measures.empty? + # also try nested 2-deep to support openstudio-measures + measures = Dir.glob(File.join(measures_dir, '**/**/measure.rb')) + end + + result = [] + measures.each { |m| result << File.dirname(m) } + return result + end + + ## + # Get path to all measures dirs found under measure dir. + ## + # @param [String] measures_dir Measures directory + ## + # @return [Array] returns path to all directories containing measures found under measure dir + def get_measure_dirs_in_dir(measures_dir) + measures = Dir.glob(File.join(measures_dir, '**/measure.rb')) + if measures.empty? + # also try nested 2-deep to support openstudio-measures + measures = Dir.glob(File.join(measures_dir, '**/**/measure.rb')) + end + + result = [] + measures.each { |m| result << File.dirname(File.dirname(m)) } + + return result.uniq + end + + ## + # Run the OpenStudio CLI command to test measures on given directory + # Returns true if the command completes successfully, false otherwise. + # measures_dir configured in rake_task + ## + # @return [Boolean] + def test_measures_with_cli(measures_dir) + puts 'Testing measures with CLI system call' + if measures_dir.nil? || measures_dir.empty? + puts 'Measures dir is nil or empty' + return true + end + + puts "measures path: #{measures_dir}" + + cli = OpenStudio.getOpenStudioCLI + + the_call = '' + if @gemfile_path + if @bundle_without_string.empty? + the_call = "#{cli} --verbose --bundle '#{@gemfile_path}' --bundle_path '#{@bundle_install_path}' measure -r '#{measures_dir}'" + else + the_call = "#{cli} --verbose --bundle '#{@gemfile_path}' --bundle_path '#{@bundle_install_path}' --bundle_without '#{@bundle_without_string}' measure -r '#{measures_dir}'" + end + else + the_call = "#{cli} --verbose measure -r #{measures_dir}" + end + + puts 'SYSTEM CALL:' + puts the_call + STDOUT.flush + result = run_command(the_call, get_clean_env) + puts "DONE, result = #{result}" + STDOUT.flush + + return result + end + + ## + # Run the OpenStudio CLI command to update measures on given directory + # Returns true if the command completes successfully, false otherwise. + ## + # @return [Boolean] + def update_measures(measures_dir) + puts 'Updating measures with CLI system call' + if measures_dir.nil? || measures_dir.empty? + puts 'Measures dir is nil or empty' + return true + end + + result = true + # DLM: this is a temporary workaround to handle OpenStudio-Measures + get_measure_dirs_in_dir(measures_dir).each do |m_dir| + puts "measures path: #{m_dir}" + + cli = OpenStudio.getOpenStudioCLI + + the_call = '' + if @gemfile_path + if @bundle_without_string.empty? + the_call = "#{cli} --verbose --bundle '#{@gemfile_path}' --bundle_path '#{@bundle_install_path}' measure -t '#{m_dir}'" + else + the_call = "#{cli} --verbose --bundle '#{@gemfile_path}' --bundle_path '#{@bundle_install_path}' --bundle_without '#{@bundle_without_string}' measure -t '#{m_dir}'" + end + else + the_call = "#{cli} --verbose measure -t '#{m_dir}'" + end + + puts 'SYSTEM CALL:' + puts the_call + STDOUT.flush + result &&= run_command(the_call, get_clean_env) + puts "DONE, result = #{result}" + STDOUT.flush + end + + return result + end + + ## + # List measures in given directory + # Returns true if the command completes successfully, false otherwise. + ## + # @return [Boolean] + + ## + def list_measures(measures_dir) + puts 'Listing measures' + if measures_dir.nil? || measures_dir.empty? + puts 'Measures dir is nil or empty' + return true + end + + puts "measures path: #{measures_dir}" + + # this is to accommodate a single measures dir (like most gems) + # or a repo with multiple directories fo measures (like OpenStudio-measures) + measures = Dir.glob(File.join(measures_dir, '**/measure.rb')) + if measures.empty? + # also try nested 2-deep to support openstudio-measures + measures = Dir.glob(File.join(measures_dir, '**/**/measure.rb')) + end + puts "#{measures.length} MEASURES FOUND" + measures.each do |measure| + name = measure.split('/')[-2] + puts name.to_s + end + end + + # Update measures by copying in the latest resource files from the Extension gem into + # the measures' respective resources folders. + # measures_dir and core_dir configured in rake_task + # Returns true if the command completes successfully, false otherwise. + # + # @return [Boolean] + def copy_core_files(measures_dir, core_dir) + puts 'Copying measure resources' + if measures_dir.nil? || measures_dir.empty? + puts 'Measures dir is nil or empty' + return true + end + + result = false + puts 'Copying updated resource files from extension core directory to individual measures.' + puts 'Only files that have actually been changed will be listed.' + + # get all resource files in the core dir + resource_files = Dir.glob(File.join(core_dir, '/*.*')) + + # this is to accommodate a single measures dir (like most gems) + # or a repo with multiple directories fo measures (like OpenStudio-measures) + measures = Dir.glob(File.join(measures_dir, '**/resources/*.rb')) + if measures.empty? + # also try nested 2-deep to support openstudio-measures + measures = Dir.glob(File.join(measures_dir, '**/**/resources/*.rb')) + end + + # Note: some older measures like AEDG use 'OsLib_SomeName' instead of 'os_lib_some_name' + # this script isn't replacing those copies + + # loop through resource files + resource_files.each do |resource_file| + # loop through measure dirs looking for matching file + measures.each do |measure| + next unless File.basename(measure) == File.basename(resource_file) + next if FileUtils.identical?(resource_file, File.path(measure)) + + puts "Replacing #{measure} with #{resource_file}." + FileUtils.cp(resource_file, File.path(measure)) + end + end + result = true + + return result + end + + # Update measures by adding license file + # measures_dir and doc_templates_dir configured in rake_task + # Returns true if the command completes successfully, false otherwise. + ## + # @return [Boolean] + def add_measure_license(measures_dir, doc_templates_dir) + puts 'Adding measure licenses' + if measures_dir.nil? || measures_dir.empty? + puts 'Measures dir is nil or empty' + return true + elsif doc_templates_dir.nil? || doc_templates_dir.empty? + puts 'Doc templates dir is nil or empty' + return false + end + + result = false + license_file = File.join(doc_templates_dir, 'LICENSE.md') + puts "License file path: #{license_file}" + + raise "License file not found '#{license_file}'" if !File.exist?(license_file) + + measures = Dir["#{measures_dir}/**/measure.rb"] + if measures.empty? + # also try nested 2-deep to support openstudio-measures + measures = Dir["#{measures_dir}/**/**/measure.rb"] + end + measures.each do |measure| + FileUtils.cp(license_file, "#{File.dirname(measure)}/LICENSE.md") + end + result = true + return result + end + + # Update measures by adding license file + # measures_dir and doc_templates_dir configured in rake_task + # Returns true if the command completes successfully, false otherwise. + ## + # @return [Boolean] + def add_measure_readme(measures_dir, doc_templates_dir) + puts 'Adding measure readmes' + if measures_dir.nil? || measures_dir.empty? + puts 'Measures dir is nil or empty' + return true + elsif doc_templates_dir.nil? || doc_templates_dir.empty? + puts 'Measures files dir is nil or empty' + return false + end + + result = false + readme_file = File.join(doc_templates_dir, 'README.md.erb') + puts "Readme file path: #{readme_file}" + + raise "Readme file not found '#{readme_file}'" if !File.exist?(readme_file) + + measures = Dir["#{measures_dir}/**/measure.rb"] + if measures.empty? + # also try nested 2-deep to support openstudio-measures + measures = Dir["#{measures_dir}/**/**/measure.rb"] + end + measures.each do |measure| + next if File.exist?("#{File.dirname(measure)}/README.md.erb") + next if File.exist?("#{File.dirname(measure)}/README.md") + + puts "adding template README to #{measure}" + FileUtils.cp(readme_file, "#{File.dirname(measure)}/README.md.erb") + end + result = true + return result + end + + def update_copyright(root_dir, doc_templates_dir) + if root_dir.nil? || root_dir.empty? + puts 'Root dir is nil or empty' + return false + elsif doc_templates_dir.nil? || doc_templates_dir.empty? + puts 'Doc templates dir is nil or empty' + return false + end + + if File.exist?(File.join(doc_templates_dir, 'LICENSE.md')) + if File.exist?(File.join(root_dir, 'LICENSE.md')) + puts 'updating LICENSE.md in root dir' + FileUtils.cp(File.join(doc_templates_dir, 'LICENSE.md'), File.join(root_dir, 'LICENSE.md')) + end + end + + ruby_regex = /^\#\s?[\#\*]{12,}.*copyright.*?\#\s?[\#\*]{12,}\s*$/mi + erb_regex = /^<%\s*\#\s?[\#\*]{12,}.*copyright.*?\#\s?[\#\*]{12,}\s*%>$/mi + js_regex = /^\/\* @preserve.*copyright.*license.{2}\*\//mi + + filename = File.join(doc_templates_dir, 'copyright_ruby.txt') + puts "Copyright file path: #{filename}" + raise "Copyright file not found '#{filename}'" if !File.exist?(filename) + + file = File.open(filename, 'r') + ruby_header_text = file.read + file.close + ruby_header_text.strip! + ruby_header_text += "\n" + + filename = File.join(doc_templates_dir, 'copyright_erb.txt') + puts "Copyright file path: #{filename}" + raise "Copyright file not found '#{filename}'" if !File.exist?(filename) + + file = File.open(filename, 'r') + erb_header_text = file.read + file.close + erb_header_text.strip! + erb_header_text += "\n" + + filename = File.join(doc_templates_dir, 'copyright_js.txt') + puts "Copyright file path: #{filename}" + raise "Copyright file not found '#{filename}'" if !File.exist?(filename) + + file = File.open(filename, 'r') + js_header_text = file.read + file.close + js_header_text.strip! + js_header_text += "\n" + + raise 'bad copyright_ruby.txt' if ruby_header_text !~ ruby_regex + raise 'bad copyright_erb.txt' if erb_header_text !~ erb_regex + raise 'bad copyright_js.txt' if js_header_text !~ js_regex + + # look for .rb, .html.erb, and .js.erb + paths = [ + { glob: "#{root_dir}/**/*.rb", license: ruby_header_text, regex: ruby_regex }, + { glob: "#{root_dir}/**/*.html.erb", license: erb_header_text, regex: erb_regex }, + { glob: "#{root_dir}/**/*.js.erb", license: js_header_text, regex: js_regex } + ] + + puts "Encoding.default_external = #{Encoding.default_external}" + puts "Encoding.default_internal = #{Encoding.default_internal}" + + paths.each do |path| + Dir[path[:glob]].each do |file| + puts "Updating license in file #{file}" + f = File.read(file) + if f =~ path[:regex] + puts ' License found -- updating' + File.open(file, 'w') { |write| write << f.gsub(path[:regex], path[:license]) } + elsif f =~ /\(C\)/i || f =~ /\(Copyright\)/i + puts ' File already has copyright -- skipping' + else + puts ' No license found -- adding' + if f =~ /#!/ + puts ' CANNOT add license to file automatically, add it manually and it will update automatically in the future' + next + end + File.open(file, 'w') { |write| write << f.insert(0, path[:license] + "\n") } + end + end + end + end + + ## + # Run the OpenStudio CLI on an OSW. The OSW is configured to include measure and file locations for all loaded OpenStudio Extensions. + ## + # @param [String, Hash] in_osw If string this is the path to an OSW file on disk, if Hash it is loaded JSON with symbolized keys + # @param [String] run_dir Directory to run the OSW in, will be created if does not exist + ## + # @return [Boolean] True if command succeeded, false otherwise # DLM: should this return path to out.osw instead? + def run_osw(in_osw, run_dir) + run_dir = File.absolute_path(run_dir) + + if in_osw.is_a?(String) + in_osw_path = in_osw + raise "'#{in_osw_path}' does not exist" if !File.exist?(in_osw_path) + + in_osw = {} + File.open(in_osw_path, 'r') do |file| + in_osw = JSON.parse(file.read, symbolize_names: true) + end + end + + osw = OpenStudio::Extension.configure_osw(in_osw) + osw[:run_directory] = run_dir + + FileUtils.mkdir_p(run_dir) + + run_osw_path = File.join(run_dir, 'in.osw') + File.open(run_osw_path, 'w') do |file| + file.puts JSON.pretty_generate(osw) + end + + if @options[:run_simulations] + cli = OpenStudio.getOpenStudioCLI + out_log = run_osw_path + '.log' + # if Gem.win_platform? + # # out_log = "nul" + # else + # # out_log = "/dev/null" + # end + + the_call = '' + verbose_string = '' + if @options[:verbose] + verbose_string = ' --verbose' + end + if @gemfile_path + if @bundle_without_string.empty? + the_call = "#{cli}#{verbose_string} --bundle '#{@gemfile_path}' --bundle_path '#{@bundle_install_path}' run -w '#{run_osw_path}' 2>&1 > \"#{out_log}\"" + else + the_call = "#{cli}#{verbose_string} --bundle '#{@gemfile_path}' --bundle_path '#{@bundle_install_path}' --bundle_without '#{@bundle_without_string}' run -w '#{run_osw_path}' 2>&1 > \"#{out_log}\"" + end + else + the_call = "#{cli}#{verbose_string} run -w '#{run_osw_path}' 2>&1 > \"#{out_log}\"" + end + + puts 'SYSTEM CALL:' + puts the_call + STDOUT.flush + result = run_command(the_call, get_clean_env) + puts "DONE, result = #{result}" + STDOUT.flush + else + puts 'simulations are not performed, since to the @options[:run_simulations] is set to false' + end + + # DLM: this does not always return false for failed CLI runs, consider checking for failed.job file as backup test + + return result + end + + # run osws, return any failure messages + def run_osws(osw_files, num_parallel = @options[:num_parallel], max_to_run = @options[:max_datapoints]) + failures = [] + osw_files = osw_files.slice(0, [osw_files.size, max_to_run].min) + + Parallel.each(osw_files, in_threads: num_parallel) do |osw| + result = run_osw(osw, File.dirname(osw)) + if !result + failures << "Failed to run OSW '#{osw}'" + end + end + + return failures + end + end + end +end