dotenv 2.8.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/dotenv/parser.rb CHANGED
@@ -2,77 +2,96 @@ require "dotenv/substitutions/variable"
2
2
  require "dotenv/substitutions/command" if RUBY_VERSION > "1.8.7"
3
3
 
4
4
  module Dotenv
5
+ # Error raised when encountering a syntax error while parsing a .env file.
5
6
  class FormatError < SyntaxError; end
6
7
 
7
- # This class enables parsing of a string for key value pairs to be returned
8
- # and stored in the Environment. It allows for variable substitutions and
9
- # exporting of variables.
8
+ # Parses the `.env` file format into key/value pairs.
9
+ # It allows for variable substitutions, command substitutions, and exporting of variables.
10
10
  class Parser
11
- @substitutions =
12
- [Dotenv::Substitutions::Variable, Dotenv::Substitutions::Command]
11
+ @substitutions = [
12
+ Dotenv::Substitutions::Command,
13
+ Dotenv::Substitutions::Variable
14
+ ]
13
15
 
14
16
  LINE = /
15
- (?:^|\A) # beginning of line
16
- \s* # leading whitespace
17
- (?:export\s+)? # optional export
18
- ([\w.]+) # key
19
- (?:\s*=\s*?|:\s+?) # separator
20
- ( # optional value begin
21
- \s*'(?:\\'|[^'])*' # single quoted value
22
- | # or
23
- \s*"(?:\\"|[^"])*" # double quoted value
24
- | # or
25
- [^\#\r\n]+ # unquoted value
26
- )? # value end
27
- \s* # trailing whitespace
28
- (?:\#.*)? # optional comment
29
- (?:$|\z) # end of line
17
+ (?:^|\A) # beginning of line
18
+ \s* # leading whitespace
19
+ (?<export>export\s+)? # optional export
20
+ (?<key>[\w.]+) # key
21
+ (?: # optional separator and value
22
+ (?:\s*=\s*?|:\s+?) # separator
23
+ (?<value> # optional value begin
24
+ \s*'(?:\\'|[^'])*' # single quoted value
25
+ | # or
26
+ \s*"(?:\\"|[^"])*" # double quoted value
27
+ | # or
28
+ [^\#\n]+ # unquoted value
29
+ )? # value end
30
+ )? # separator and value end
31
+ \s* # trailing whitespace
32
+ (?:\#.*)? # optional comment
33
+ (?:$|\z) # end of line
30
34
  /x
31
35
 
36
+ QUOTED_STRING = /\A(['"])(.*)\1\z/m
37
+
32
38
  class << self
33
39
  attr_reader :substitutions
34
40
 
35
- def call(string, is_load = false)
36
- new(string, is_load).call
41
+ def call(...)
42
+ new(...).call
37
43
  end
38
44
  end
39
45
 
40
- def initialize(string, is_load = false)
41
- @string = string
46
+ def initialize(string, overwrite: false)
47
+ # Convert line breaks to same format
48
+ @string = string.gsub(/\r\n?/, "\n")
42
49
  @hash = {}
43
- @is_load = is_load
50
+ @overwrite = overwrite
44
51
  end
45
52
 
46
53
  def call
47
- # Convert line breaks to same format
48
- lines = @string.gsub(/\r\n?/, "\n")
49
- # Process matches
50
- lines.scan(LINE).each do |key, value|
51
- @hash[key] = parse_value(value || "")
52
- end
53
- # Process non-matches
54
- lines.gsub(LINE, "").split(/[\n\r]+/).each do |line|
55
- parse_line(line)
54
+ @string.scan(LINE) do
55
+ match = $LAST_MATCH_INFO
56
+
57
+ if existing?(match[:key])
58
+ # Use value from already defined variable
59
+ @hash[match[:key]] = ENV[match[:key]]
60
+ elsif match[:export] && !match[:value]
61
+ # Check for exported variable with no value
62
+ if !@hash.member?(match[:key])
63
+ raise FormatError, "Line #{match.to_s.inspect} has an unset variable"
64
+ end
65
+ else
66
+ @hash[match[:key]] = parse_value(match[:value] || "")
67
+ end
56
68
  end
69
+
57
70
  @hash
58
71
  end
59
72
 
60
73
  private
61
74
 
62
- def parse_line(line)
63
- if line.split.first == "export"
64
- if variable_not_set?(line)
65
- raise FormatError, "Line #{line.inspect} has an unset variable"
66
- end
67
- end
75
+ # Determine if a variable is already defined and should not be overwritten.
76
+ def existing?(key)
77
+ !@overwrite && key != "DOTENV_LINEBREAK_MODE" && ENV.key?(key)
68
78
  end
69
79
 
70
80
  def parse_value(value)
71
81
  # Remove surrounding quotes
72
- value = value.strip.sub(/\A(['"])(.*)\1\z/m, '\2')
82
+ value = value.strip.sub(QUOTED_STRING, '\2')
73
83
  maybe_quote = Regexp.last_match(1)
74
- value = unescape_value(value, maybe_quote)
75
- perform_substitutions(value, maybe_quote)
84
+
85
+ # Expand new lines in double quoted values
86
+ value = expand_newlines(value) if maybe_quote == '"'
87
+
88
+ # Unescape characters and performs substitutions unless value is single quoted
89
+ if maybe_quote != "'"
90
+ value = unescape_characters(value)
91
+ self.class.substitutions.each { |proc| value = proc.call(value, @hash) }
92
+ end
93
+
94
+ value
76
95
  end
77
96
 
78
97
  def unescape_characters(value)
@@ -80,30 +99,11 @@ module Dotenv
80
99
  end
81
100
 
82
101
  def expand_newlines(value)
83
- value.gsub('\n', "\n").gsub('\r', "\r")
84
- end
85
-
86
- def variable_not_set?(line)
87
- !line.split[1..-1].all? { |var| @hash.member?(var) }
88
- end
89
-
90
- def unescape_value(value, maybe_quote)
91
- if maybe_quote == '"'
92
- unescape_characters(expand_newlines(value))
93
- elsif maybe_quote.nil?
94
- unescape_characters(value)
102
+ if (@hash["DOTENV_LINEBREAK_MODE"] || ENV["DOTENV_LINEBREAK_MODE"]) == "legacy"
103
+ value.gsub('\n', "\n").gsub('\r', "\r")
95
104
  else
96
- value
105
+ value.gsub('\n', "\\\\\\n").gsub('\r', "\\\\\\r")
97
106
  end
98
107
  end
99
-
100
- def perform_substitutions(value, maybe_quote)
101
- if maybe_quote != "'"
102
- self.class.substitutions.each do |proc|
103
- value = proc.call(value, @hash, @is_load)
104
- end
105
- end
106
- value
107
- end
108
108
  end
109
109
  end
@@ -0,0 +1,10 @@
1
+ # If you use gems that require environment variables to be set before they are
2
+ # loaded, then list `dotenv` in the `Gemfile` before those other gems and
3
+ # require `dotenv/load`.
4
+ #
5
+ # gem "dotenv", require: "dotenv/load"
6
+ # gem "gem-that-requires-env-variables"
7
+ #
8
+
9
+ require "dotenv/load"
10
+ warn '[DEPRECATION] `require "dotenv/rails-now"` is deprecated. Use `require "dotenv/load"` instead.', caller(1..1).first
@@ -0,0 +1,111 @@
1
+ # Since rubygems doesn't support optional dependencies, we have to manually check
2
+ unless Gem::Requirement.new(">= 6.1").satisfied_by?(Gem::Version.new(Rails.version))
3
+ warn "dotenv 3.0 only supports Rails 6.1 or later. Use dotenv ~> 2.0."
4
+ return
5
+ end
6
+
7
+ require "dotenv/replay_logger"
8
+ require "dotenv/log_subscriber"
9
+
10
+ Dotenv.instrumenter = ActiveSupport::Notifications
11
+
12
+ # Watch all loaded env files with Spring
13
+ ActiveSupport::Notifications.subscribe("load.dotenv") do |*args|
14
+ if defined?(Spring) && Spring.respond_to?(:watch)
15
+ event = ActiveSupport::Notifications::Event.new(*args)
16
+ Spring.watch event.payload[:env].filename if Rails.application
17
+ end
18
+ end
19
+
20
+ module Dotenv
21
+ # Rails integration for using Dotenv to load ENV variables from a file
22
+ class Rails < ::Rails::Railtie
23
+ delegate :files, :files=, :overwrite, :overwrite=, :autorestore, :autorestore=, :logger, to: "config.dotenv"
24
+
25
+ def initialize
26
+ super
27
+ config.dotenv = ActiveSupport::OrderedOptions.new.update(
28
+ # Rails.logger is not available yet, so we'll save log messages and replay them when it is
29
+ logger: Dotenv::ReplayLogger.new,
30
+ overwrite: false,
31
+ files: [
32
+ ".env.#{env}.local",
33
+ (".env.local" unless env.test?),
34
+ ".env.#{env}",
35
+ ".env"
36
+ ].compact,
37
+ autorestore: env.test? && !defined?(ClimateControl) && !defined?(IceAge)
38
+ )
39
+ end
40
+
41
+ # Public: Load dotenv
42
+ #
43
+ # This will get called during the `before_configuration` callback, but you
44
+ # can manually call `Dotenv::Rails.load` if you needed it sooner.
45
+ def load
46
+ Dotenv.load(*files.map { |file| root.join(file).to_s }, overwrite: overwrite)
47
+ end
48
+
49
+ def overload
50
+ deprecator.warn("Dotenv::Rails.overload is deprecated. Set `Dotenv::Rails.overwrite = true` and call Dotenv::Rails.load instead.")
51
+ Dotenv.load(*files.map { |file| root.join(file).to_s }, overwrite: true)
52
+ end
53
+
54
+ # Internal: `Rails.root` is nil in Rails 4.1 before the application is
55
+ # initialized, so this falls back to the `RAILS_ROOT` environment variable,
56
+ # or the current working directory.
57
+ def root
58
+ ::Rails.root || Pathname.new(ENV["RAILS_ROOT"] || Dir.pwd)
59
+ end
60
+
61
+ # Set a new logger and replay logs
62
+ def logger=(new_logger)
63
+ logger.replay new_logger if logger.is_a?(ReplayLogger)
64
+ config.dotenv.logger = new_logger
65
+ end
66
+
67
+ # The current environment that the app is running in.
68
+ #
69
+ # When running `rake`, the Rails application is initialized in development, so we have to
70
+ # check which rake tasks are being run to determine the environment.
71
+ #
72
+ # See https://github.com/bkeepers/dotenv/issues/219
73
+ def env
74
+ @env ||= if defined?(Rake.application) && Rake.application.top_level_tasks.grep(TEST_RAKE_TASKS).any?
75
+ env = Rake.application.options.show_tasks ? "development" : "test"
76
+ ActiveSupport::EnvironmentInquirer.new(env)
77
+ else
78
+ ::Rails.env
79
+ end
80
+ end
81
+ TEST_RAKE_TASKS = /^(default$|test(:|$)|parallel:spec|spec(:|$))/
82
+
83
+ def deprecator # :nodoc:
84
+ @deprecator ||= ActiveSupport::Deprecation.new
85
+ end
86
+
87
+ # Rails uses `#method_missing` to delegate all class methods to the
88
+ # instance, which means `Kernel#load` gets called here. We don't want that.
89
+ def self.load
90
+ instance.load
91
+ end
92
+
93
+ initializer "dotenv", after: :initialize_logger do |app|
94
+ if logger.is_a?(ReplayLogger)
95
+ self.logger = ActiveSupport::TaggedLogging.new(::Rails.logger).tagged("dotenv")
96
+ end
97
+ end
98
+
99
+ initializer "dotenv.deprecator" do |app|
100
+ app.deprecators[:dotenv] = deprecator if app.respond_to?(:deprecators)
101
+ end
102
+
103
+ initializer "dotenv.autorestore" do |app|
104
+ require "dotenv/autorestore" if autorestore
105
+ end
106
+
107
+ config.before_configuration { load }
108
+ end
109
+
110
+ Railtie = ActiveSupport::Deprecation::DeprecatedConstantProxy.new("Dotenv::Railtie", "Dotenv::Rails", Dotenv::Rails.deprecator)
111
+ end
@@ -0,0 +1,20 @@
1
+ module Dotenv
2
+ # A logger that can be used before the apps real logger is initialized.
3
+ class ReplayLogger < Logger
4
+ def initialize
5
+ super(nil) # Doesn't matter what this is, it won't be used.
6
+ @logs = []
7
+ end
8
+
9
+ # Override the add method to store logs so we can replay them to a real logger later.
10
+ def add(*args, &block)
11
+ @logs.push([args, block])
12
+ end
13
+
14
+ # Replay the store logs to a real logger.
15
+ def replay(logger)
16
+ @logs.each { |args, block| logger.add(*args, &block) }
17
+ @logs.clear
18
+ end
19
+ end
20
+ end
@@ -20,7 +20,7 @@ module Dotenv
20
20
  )
21
21
  /x
22
22
 
23
- def call(value, _env, _is_load)
23
+ def call(value, env)
24
24
  # Process interpolated shell commands
25
25
  value.gsub(INTERPOLATED_SHELL_COMMAND) do |*|
26
26
  # Eliminate opening and closing parentheses
@@ -28,10 +28,10 @@ module Dotenv
28
28
 
29
29
  if $LAST_MATCH_INFO[:backslash]
30
30
  # Command is escaped, don't replace it.
31
- $LAST_MATCH_INFO[0][1..-1]
31
+ $LAST_MATCH_INFO[0][1..]
32
32
  else
33
33
  # Execute the command and return the value
34
- `#{command}`.chomp
34
+ `#{Variable.call(command, env)}`.chomp
35
35
  end
36
36
  end
37
37
  end
@@ -12,29 +12,23 @@ module Dotenv
12
12
  VARIABLE = /
13
13
  (\\)? # is it escaped with a backslash?
14
14
  (\$) # literal $
15
- (?!\() # shouldnt be followed by paranthesis
15
+ (?!\() # shouldn't be followed by parenthesis
16
16
  \{? # allow brace wrapping
17
17
  ([A-Z0-9_]+)? # optional alpha nums
18
18
  \}? # closing brace
19
19
  /xi
20
20
 
21
- def call(value, env, is_load)
22
- combined_env = is_load ? env.merge(ENV) : ENV.to_h.merge(env)
21
+ def call(value, env)
23
22
  value.gsub(VARIABLE) do |variable|
24
23
  match = $LAST_MATCH_INFO
25
- substitute(match, variable, combined_env)
26
- end
27
- end
28
-
29
- private
30
24
 
31
- def substitute(match, variable, env)
32
- if match[1] == "\\"
33
- variable[1..-1]
34
- elsif match[3]
35
- env.fetch(match[3], "")
36
- else
37
- variable
25
+ if match[1] == "\\"
26
+ variable[1..]
27
+ elsif match[3]
28
+ env[match[3]] || ENV[match[3]] || ""
29
+ else
30
+ variable
31
+ end
38
32
  end
39
33
  end
40
34
  end
@@ -10,17 +10,35 @@ module Dotenv
10
10
  File.open(@env_file, "r") do |env_file|
11
11
  File.open("#{@env_file}.template", "w") do |env_template|
12
12
  env_file.each do |line|
13
- env_template.puts template_line(line)
13
+ if is_comment?(line)
14
+ env_template.puts line
15
+ elsif (var = var_defined?(line))
16
+ if line.match(EXPORT_COMMAND)
17
+ env_template.puts "export #{var}=#{var}"
18
+ else
19
+ env_template.puts "#{var}=#{var}"
20
+ end
21
+ elsif line_blank?(line)
22
+ env_template.puts
23
+ end
14
24
  end
15
25
  end
16
26
  end
17
27
  end
18
28
 
19
- def template_line(line)
20
- var, value = line.split("=")
21
- template = var.gsub(EXPORT_COMMAND, "")
22
- is_a_comment = var.strip[0].eql?("#")
23
- value.nil? || is_a_comment ? line : "#{var}=#{template}"
29
+ private
30
+
31
+ def is_comment?(line)
32
+ line.strip.start_with?("#")
33
+ end
34
+
35
+ def var_defined?(line)
36
+ match = Dotenv::Parser::LINE.match(line)
37
+ match && match[:key]
38
+ end
39
+
40
+ def line_blank?(line)
41
+ line.strip.length.zero?
24
42
  end
25
43
  end
26
44
  end
@@ -1,3 +1,3 @@
1
1
  module Dotenv
2
- VERSION = "2.8.1".freeze
2
+ VERSION = "3.2.0".freeze
3
3
  end
data/lib/dotenv.rb CHANGED
@@ -1,75 +1,133 @@
1
+ require "dotenv/version"
1
2
  require "dotenv/parser"
2
3
  require "dotenv/environment"
3
4
  require "dotenv/missing_keys"
5
+ require "dotenv/diff"
4
6
 
5
- # The top level Dotenv module. The entrypoint for the application logic.
7
+ # Shim to load environment variables from `.env files into `ENV`.
6
8
  module Dotenv
7
- class << self
8
- attr_accessor :instrumenter
9
- end
9
+ extend self
10
+
11
+ # An internal monitor to synchronize access to ENV in multi-threaded environments.
12
+ SEMAPHORE = Monitor.new
13
+ private_constant :SEMAPHORE
10
14
 
11
- module_function
15
+ attr_accessor :instrumenter
12
16
 
13
- def load(*filenames)
14
- with(*filenames) do |f|
15
- ignoring_nonexistent_files do
16
- env = Environment.new(f, true)
17
- instrument("dotenv.load", env: env) { env.apply }
17
+ # Loads environment variables from one or more `.env` files. See `#parse` for more details.
18
+ def load(*filenames, overwrite: false, ignore: true)
19
+ parse(*filenames, overwrite: overwrite, ignore: ignore) do |env|
20
+ instrument(:load, env: env) do |payload|
21
+ update(env, overwrite: overwrite)
18
22
  end
19
23
  end
20
24
  end
21
25
 
22
- # same as `load`, but raises Errno::ENOENT if any files don't exist
26
+ # Same as `#load`, but raises Errno::ENOENT if any files don't exist
23
27
  def load!(*filenames)
24
- with(*filenames) do |f|
25
- env = Environment.new(f, true)
26
- instrument("dotenv.load", env: env) { env.apply }
27
- end
28
+ load(*filenames, ignore: false)
29
+ end
30
+
31
+ # same as `#load`, but will overwrite existing values in `ENV`
32
+ def overwrite(*filenames)
33
+ load(*filenames, overwrite: true)
34
+ end
35
+ alias_method :overload, :overwrite
36
+
37
+ # same as `#overwrite`, but raises Errno::ENOENT if any files don't exist
38
+ def overwrite!(*filenames)
39
+ load(*filenames, overwrite: true, ignore: false)
28
40
  end
41
+ alias_method :overload!, :overwrite!
42
+
43
+ # Parses the given files, yielding for each file if a block is given.
44
+ #
45
+ # @param filenames [String, Array<String>] Files to parse
46
+ # @param overwrite [Boolean] Overwrite existing `ENV` values
47
+ # @param ignore [Boolean] Ignore non-existent files
48
+ # @param block [Proc] Block to yield for each parsed `Dotenv::Environment`
49
+ # @return [Hash] parsed key/value pairs
50
+ def parse(*filenames, overwrite: false, ignore: true, &block)
51
+ filenames << ".env" if filenames.empty?
52
+ filenames = filenames.reverse if overwrite
29
53
 
30
- # same as `load`, but will override existing values in `ENV`
31
- def overload(*filenames)
32
- with(*filenames) do |f|
33
- ignoring_nonexistent_files do
34
- env = Environment.new(f, false)
35
- instrument("dotenv.overload", env: env) { env.apply! }
54
+ filenames.reduce({}) do |hash, filename|
55
+ begin
56
+ env = Environment.new(File.expand_path(filename), overwrite: overwrite)
57
+ env = block.call(env) if block
58
+ rescue Errno::ENOENT, Errno::EISDIR
59
+ raise unless ignore
36
60
  end
61
+
62
+ hash.merge! env || {}
37
63
  end
38
64
  end
39
65
 
40
- # same as `overload`, but raises Errno::ENOENT if any files don't exist
41
- def overload!(*filenames)
42
- with(*filenames) do |f|
43
- env = Environment.new(f, false)
44
- instrument("dotenv.overload", env: env) { env.apply! }
66
+ # Save the current `ENV` to be restored later
67
+ def save
68
+ instrument(:save) do |payload|
69
+ @diff = payload[:diff] = Dotenv::Diff.new
45
70
  end
46
71
  end
47
72
 
48
- # returns a hash of parsed key/value pairs but does not modify ENV
49
- def parse(*filenames)
50
- with(*filenames) do |f|
51
- ignoring_nonexistent_files do
52
- Environment.new(f, false)
53
- end
73
+ # Restore `ENV` to a given state
74
+ #
75
+ # @param env [Hash] Hash of keys and values to restore, defaults to the last saved state
76
+ # @param safe [Boolean] Is it safe to modify `ENV`? Defaults to `true` in the main thread, otherwise raises an error.
77
+ def restore(env = @diff&.a, safe: Thread.current == Thread.main)
78
+ # No previously saved or provided state to restore
79
+ return unless env
80
+
81
+ diff = Dotenv::Diff.new(b: env)
82
+ return unless diff.any?
83
+
84
+ unless safe
85
+ raise ThreadError, <<~EOE.tr("\n", " ")
86
+ Dotenv.restore is not thread safe. Use `Dotenv.modify { }` to update ENV for the duration
87
+ of the block in a thread safe manner, or call `Dotenv.restore(safe: true)` to ignore
88
+ this error.
89
+ EOE
54
90
  end
91
+ instrument(:restore, diff: diff) { ENV.replace(env) }
55
92
  end
56
93
 
57
- # Internal: Helper to expand list of filenames.
94
+ # Update `ENV` with the given hash of keys and values
58
95
  #
59
- # Returns a hash of all the loaded environment variables.
60
- def with(*filenames)
61
- filenames << ".env" if filenames.empty?
62
-
63
- filenames.reduce({}) do |hash, filename|
64
- hash.merge!(yield(File.expand_path(filename)) || {})
96
+ # @param env [Hash] Hash of keys and values to set in `ENV`
97
+ # @param overwrite [Boolean|:warn] Overwrite existing `ENV` values
98
+ def update(env = {}, overwrite: false)
99
+ instrument(:update) do |payload|
100
+ diff = payload[:diff] = Dotenv::Diff.new do
101
+ ENV.update(env.transform_keys(&:to_s)) do |key, old_value, new_value|
102
+ # This block is called when a key exists. Return the new value if overwrite is true.
103
+ case overwrite
104
+ when :warn
105
+ # not printing the value since that could be a secret
106
+ warn "Warning: dotenv not overwriting ENV[#{key.inspect}]"
107
+ old_value
108
+ when true then new_value
109
+ when false then old_value
110
+ else raise ArgumentError, "Invalid value for overwrite: #{overwrite.inspect}"
111
+ end
112
+ end
113
+ end
114
+ diff.env
65
115
  end
66
116
  end
67
117
 
68
- def instrument(name, payload = {}, &block)
69
- if instrumenter
70
- instrumenter.instrument(name, payload, &block)
71
- else
72
- yield
118
+ # Modify `ENV` for the block and restore it to its previous state afterwards.
119
+ #
120
+ # Note that the block is synchronized to prevent concurrent modifications to `ENV`,
121
+ # so multiple threads will be executed serially.
122
+ #
123
+ # @param env [Hash] Hash of keys and values to set in `ENV`
124
+ def modify(env = {}, &block)
125
+ SEMAPHORE.synchronize do
126
+ diff = Dotenv::Diff.new
127
+ update(env, overwrite: true)
128
+ block.call
129
+ ensure
130
+ restore(diff.a, safe: true)
73
131
  end
74
132
  end
75
133
 
@@ -79,8 +137,15 @@ module Dotenv
79
137
  raise MissingKeys, missing_keys
80
138
  end
81
139
 
82
- def ignoring_nonexistent_files
83
- yield
84
- rescue Errno::ENOENT
140
+ private
141
+
142
+ def instrument(name, payload = {}, &block)
143
+ if instrumenter
144
+ instrumenter.instrument("#{name}.dotenv", payload, &block)
145
+ else
146
+ block&.call payload
147
+ end
85
148
  end
86
149
  end
150
+
151
+ require "dotenv/rails" if defined?(Rails::Railtie)