lib/sugar_utils/file.rb
# frozen_string_literal: true
require 'solid_assert'
require 'fileutils'
require 'multi_json'
require 'timeout'
require 'tempfile'
require 'sugar_utils/file/write_options'
module SugarUtils
# @api
module File # rubocop:disable Metrics/ModuleLength
class Error < StandardError; end
# @param file [File]
# @param options [Hash]
# @option options [Integer] :timeout (10)
#
# @raise [Timeout::Error]
#
# @return [void]
def self.flock_shared(file, options = {})
timeout = options[:timeout] || 10
Timeout.timeout(timeout) { file.flock(::File::LOCK_SH) }
end
# @param file [File]
# @param options [Hash]
# @option options [Integer] :timeout (10)
#
# @raise [Timeout::Error]
#
# @return [void]
def self.flock_exclusive(file, options = {})
timeout = options[:timeout] || 10
Timeout.timeout(timeout) { file.flock(::File::LOCK_EX) }
end
# Change all of the access values for the specified file including:
# * owner
# * group
# * permissions
#
# @note Although the are all required, nil can be passed to any of them and
# those nils will be skipped. Hopefully, this will avoid conditions in the
# calling code because the optional parameters will just be passed in and
# skipped when they are missing.
#
# @param filename [String]
# @param owner [nil, Integer, String]
# @param group [nil, Integer, String]
# @param permission [nil, Integer]
#
# @raise [SugarUtils::File::Error]
#
# @return [void]
def self.change_access(filename, owner, group, permission)
FileUtils.chown(owner, group, filename)
FileUtils.chmod(permission, filename) if permission
nil
rescue SystemCallError, IOError
raise(Error, "Unable to change access on #{filename}")
end
# @param filename [String]
# @param options [Hash]
# @option options [Integer] :timeout (10)
# @option options [Boolean] :raise_on_missing (true)
# @option options [String] :value_on_missing ('') which specifies the
# value to return if the file is missing and raise_on_missing is false
# @option options [Boolean, String] :scrub_encoding scrub incorrectly
# encoded characters with this value, or with '' if the value is true
#
# @raise [SugarUtils::File::Error]
#
# @return [String]
def self.read(filename, options = {}) # rubocop:disable Metrics/MethodLength
options[:value_on_missing] ||= ''
options[:raise_on_missing] = true if options[:raise_on_missing].nil?
result =
::File.open(filename, ::File::RDONLY) do |file|
flock_shared(file, options)
file.read
end
return result unless options[:scrub_encoding]
SugarUtils.scrub_encoding(result, options[:scrub_encoding])
rescue SystemCallError, IOError
raise(Error, "Cannot read #{filename}") if options[:raise_on_missing]
options[:value_on_missing]
rescue Timeout::Error
raise(Error, "Cannot read #{filename} because it is locked")
end
# @param filename [String]
# @param options [Hash]
# @option options [Integer] :timeout (10)
# @option options [Boolean] :raise_on_missing (true)
#
# @raise [SugarUtils::File::Error]
#
# @return [Object]
def self.read_json(filename, options = {})
options[:value_on_missing] = :missing
read_result = read(filename, options)
return {} if read_result == :missing
MultiJson.load(read_result)
rescue MultiJson::ParseError
raise(Error, "Cannot parse #{filename}")
end
# Touch the specified file.
#
# @param filename [String]
# @param options [Hash]
# @option options [String, Integer] :owner
# @option options [String, Integer] :group
# @option options [Integer] :mode
# @option options [Integer] :perm
# @option options [Integer] :mtime
#
# @return [void]
def self.touch(filename, options = {})
write_options = WriteOptions.new(filename, options)
FileUtils.mkdir_p(::File.dirname(filename))
FileUtils.touch(filename, **write_options.slice(:mtime))
change_access(
filename,
write_options.owner,
write_options.group,
write_options.perm(nil)
)
end
# Write to an existing file, overwriting it, or create the file if it does
# not exist.
#
# @note Either option :mode or :perm can be used to specific the permissions
# on the file being written to. This aliasing is used because both these
# names are used in the standard library, File.open uses :perm and FileUtils
# uses :mode. The user can choose whichever alias makes their code most
# readable.
#
# @param filename [String]
# @param data [#to_s]
# @param options [Hash]
# @option options [Integer] :timeout (10)
# @option options [Boolean] :flush (false)
# @option options [String, Integer] :owner
# @option options [String, Integer] :group
# @option options [Integer] :mode (0o644)
# @option options [Integer] :perm (0o644)
#
# @raise [SugarUtils::File::Error]
#
# @return [void]
def self.write(filename, data, options = {}) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
write_options = WriteOptions.new(filename, options)
FileUtils.mkdir_p(::File.dirname(filename))
::File.open(filename, 'w+', write_options.perm) do |file|
flock_exclusive(file, options)
file.puts(data.to_s)
# Flush and fsync to be 100% sure we write this data out now because we
# are often reading it immediately and if the OS is buffering, it is
# possible we might read it before it is been physically written to
# disk. We are not worried about speed here, so this should be OKAY.
if write_options.flush?
file.flush
file.fsync
end
end
change_access(
filename,
write_options.owner,
write_options.group,
write_options.perm
)
rescue Timeout::Error
raise(Error, "Unable to write #{filename} because it is locked")
rescue SystemCallError, IOError => e
raise(Error, "Unable to write #{filename} with #{e}")
end
# Atomically write to an existing file, overwriting it, or create the file
# if it does not exist.
#
# @note Either option :mode or :perm can be used to specific the permissions
# on the file being written to. This aliasing is used because both these
# names are used in the standard library, File.open uses :perm and FileUtils
# uses :mode. The user can choose whichever alias makes their code most
# readable.
#
# @param filename [String]
# @param data [#to_s]
# @param options [Hash]
# @option options [Integer] :timeout (10)
# @option options [Boolean] :flush (false)
# @option options [String, Integer] :owner
# @option options [String, Integer] :group
# @option options [Integer] :mode (0o644)
# @option options [Integer] :perm (0o644)
#
# @raise [SugarUtils::File::Error]
#
# @return [void]
def self.atomic_write(filename, data, options = {}) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
write_options = WriteOptions.new(filename, options)
# @note This method is similar to the atomic_write which is implemented in
# ActiveSupport. We re-implemented the method because of the following:
# * we needed the method, but wanted to avoid pulling in the entire
# ActiveSupport gem.
# * we wnated to keep the behaviour and interface consistent with the other
# SugarUtils write methods
#
# @see https://apidock.com/rails/File/atomic_write/class
FileUtils.mkdir_p(::File.dirname(filename))
Tempfile.open(::File.basename(filename, '.*'), ::File.dirname(filename)) do |temp_file|
temp_file.puts(data.to_s)
# Flush and fsync to be 100% sure we write this data out now because we
# are often reading it immediately and if the OS is buffering, it is
# possible we might read it before it is been physically written to
# disk. We are not worried about speed here, so this should be OKAY.
if write_options.flush?
temp_file.flush
temp_file.fsync
end
temp_file.close
::File.open(filename, 'w+', write_options.perm) do |file|
flock_exclusive(file, options)
FileUtils.move(temp_file.path, filename)
end
end
change_access(
filename,
write_options.owner,
write_options.group,
write_options.perm
)
rescue Timeout::Error
raise(Error, "Unable to write #{filename} because it is locked")
rescue SystemCallError, IOError => e
raise(Error, "Unable to write #{filename} with #{e}")
end
# Write the data parameter as JSON to the filename path.
#
# @note Either option :mode or :perm can be used to specific the permissions
# on the file being written to. This aliasing is used because both these
# names are used in the standard library, File.open uses :perm and FileUtils
# uses :mode. The user can choose whichever alias makes their code most
# readable.
#
# @param filename [String]
# @param data [#to_json]
# @param options [Hash]
# @option options [Integer] :timeout (10)
# @option options [Boolean] :flush (false)
# @option options [String, Integer] :owner
# @option options [String, Integer] :group
# @option options [Integer] :mode (0o644)
# @option options [Integer] :perm (0o644)
#
# @raise [SugarUtils::File::Error]
#
# @return [void]
def self.write_json(filename, data, options = {})
atomic_write(filename, MultiJson.dump(data, pretty: true), options)
end
# Append to an existing file, or create the file if it does not exist.
#
# @note Either option :mode or :perm can be used to specific the permissions
# on the file being written to. This aliasing is used because both these
# names are used in the standard library, File.open uses :perm and FileUtils
# uses :mode. The user can choose whichever alias makes their code most
# readable.
#
# @param filename [String]
# @param data [#to_s]
# @param options [Hash]
# @option options [Integer] :timeout (10)
# @option options [Boolean] :flush (false)
# @option options [String, Integer] :owner
# @option options [String, Integer] :group
# @option options [Integer] :mode (0o644)
# @option options [Integer] :perm (0o644)
#
# @raise [SugarUtils::File::Error]
#
# @return [void]
def self.append(filename, data, options = {}) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
write_options = WriteOptions.new(filename, options)
FileUtils.mkdir_p(::File.dirname(filename))
::File.open(filename, 'a', write_options.perm) do |file|
flock_exclusive(file, options)
file.puts(data.to_s)
# Flush and fsync to be 100% sure we write this data out now because we
# are often reading it immediately and if the OS is buffering, it is
# possible we might read it before it is been physically written to
# disk. We are not worried about speed here, so this should be OKAY.
if write_options.flush?
file.flush
file.fsync
end
end
change_access(
filename,
write_options.owner,
write_options.group,
write_options.perm
)
rescue Timeout::Error
raise(Error, "Unable to write #{filename} because it is locked")
rescue SystemCallError, IOError => e
raise(Error, "Unable to write #{filename} with #{e}")
end
############################################################################
# Following the same pattern as the existing stdlib method deprecation
# module.
# @see http://ruby-doc.org/stdlib-2.0.0/libdoc/rubygems/rdoc/Gem/Deprecate.html
def self.deprecate_option(_method, option_name, option_repl, year, month) # rubocop:disable Metrics/MethodLength
return if Gem::Deprecate.skip
klass = is_a?(Module)
target = klass ? "#{self}." : "#{self.class}#"
# Determine the method
method = caller_locations(1, 1).first.label
# Determine the caller
external_caller = caller_locations(2, 1).first
location_of_external_caller = "#{external_caller.absolute_path}:#{external_caller.lineno}"
msg = [
"NOTE: #{target}#{method} option :#{option_name} is deprecated",
case option_repl
when :none
' with no replacement'
when String
option_repl
else
"; use :#{option_repl} instead"
end,
format(
'. It will be removed on or after %<year>4d-%<month>02d-01.',
year: year,
month: month
),
"\n#{target}#{method} called from #{location_of_external_caller}"
]
warn("#{msg.join}.")
end
private_class_method :deprecate_option
end
end