Skip to content

Commit 5d63c7f

Browse files
committed
feat: support multiple property sources with precedence
1. Environment variables (TESTCONTAINERS_* prefix) 2. User file (~/.testcontainers.properties) 3. Project file (.testcontainers.properties)
1 parent 2dd9102 commit 5d63c7f

2 files changed

Lines changed: 144 additions & 2 deletions

File tree

lib/util/properties.ex

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
defmodule Testcontainers.Util.PropertiesParser do
22
@moduledoc false
33

4-
@file_path "~/.testcontainers.properties"
4+
@user_file "~/.testcontainers.properties"
5+
@project_file ".testcontainers.properties"
6+
@env_prefix "TESTCONTAINERS_"
57

6-
def read_property_file(file_path \\ @file_path) do
8+
def read_property_file(file_path \\ @user_file) do
79
if File.exists?(Path.expand(file_path)) do
810
with {:ok, content} <- File.read(Path.expand(file_path)),
911
properties <- parse_properties(content) do
@@ -18,6 +20,76 @@ defmodule Testcontainers.Util.PropertiesParser do
1820
end
1921
end
2022

23+
@doc """
24+
Reads properties from all sources with proper precedence.
25+
26+
Configuration is read from three sources with the following precedence
27+
(highest to lowest):
28+
29+
1. Environment variables (TESTCONTAINERS_* prefix)
30+
2. User file (~/.testcontainers.properties)
31+
3. Project file (.testcontainers.properties)
32+
33+
Environment variables are converted from TESTCONTAINERS_PROPERTY_NAME format
34+
to property.name format (uppercase to lowercase, underscores to dots, prefix removed).
35+
36+
## Options
37+
38+
- `:user_file` - path to user properties file (default: ~/.testcontainers.properties)
39+
- `:project_file` - path to project properties file (default: .testcontainers.properties)
40+
- `:env_prefix` - environment variable prefix (default: TESTCONTAINERS_)
41+
42+
Returns `{:ok, map}` with merged properties.
43+
"""
44+
def read_property_sources(opts \\ []) do
45+
user_file = Keyword.get(opts, :user_file, @user_file)
46+
project_file = Keyword.get(opts, :project_file, @project_file)
47+
env_prefix = Keyword.get(opts, :env_prefix, @env_prefix)
48+
49+
project_props = read_file_silent(project_file)
50+
user_props = read_file_silent(user_file)
51+
env_props = read_env_vars(env_prefix)
52+
53+
# Merge in order of lowest to highest precedence
54+
merged =
55+
project_props
56+
|> Map.merge(user_props)
57+
|> Map.merge(env_props)
58+
59+
{:ok, merged}
60+
end
61+
62+
defp read_file_silent(file_path) do
63+
expanded = Path.expand(file_path)
64+
65+
if File.exists?(expanded) do
66+
case File.read(expanded) do
67+
{:ok, content} -> parse_properties(content)
68+
{:error, _} -> %{}
69+
end
70+
else
71+
%{}
72+
end
73+
end
74+
75+
defp read_env_vars(prefix) do
76+
System.get_env()
77+
|> Enum.filter(fn {key, _value} -> String.starts_with?(key, prefix) end)
78+
|> Enum.map(&env_to_property(&1, prefix))
79+
|> Map.new()
80+
end
81+
82+
# Converts TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED to ryuk.container.privileged
83+
defp env_to_property({key, value}, prefix) do
84+
property_key =
85+
key
86+
|> String.replace_prefix(prefix, "")
87+
|> String.downcase()
88+
|> String.replace("_", ".")
89+
90+
{property_key, value}
91+
end
92+
2193
defp parse_properties(content) do
2294
content
2395
|> String.split("\n")

test/util/properties_test.exs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
defmodule Testcontainers.Util.PropertiesParserTest do
2+
use ExUnit.Case, async: false
3+
4+
alias Testcontainers.Util.PropertiesParser
5+
6+
describe "read_property_sources/0" do
7+
test "returns empty map when no files or env vars exist" do
8+
# Clean env vars that might interfere
9+
System.get_env()
10+
|> Enum.filter(fn {k, _} -> String.starts_with?(k, "TESTCONTAINERS_") end)
11+
|> Enum.each(fn {k, _} -> System.delete_env(k) end)
12+
13+
{:ok, props} = PropertiesParser.read_property_sources()
14+
15+
# Should at least return a map (may have project file props)
16+
assert is_map(props)
17+
end
18+
19+
test "reads environment variables with TESTCONTAINERS_ prefix" do
20+
System.put_env("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED", "true")
21+
System.put_env("TESTCONTAINERS_SOME_OTHER_PROPERTY", "value")
22+
23+
{:ok, props} = PropertiesParser.read_property_sources()
24+
25+
assert props["ryuk.container.privileged"] == "true"
26+
assert props["some.other.property"] == "value"
27+
28+
# Cleanup
29+
System.delete_env("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED")
30+
System.delete_env("TESTCONTAINERS_SOME_OTHER_PROPERTY")
31+
end
32+
33+
test "environment variables take precedence over file properties" do
34+
# Set env var
35+
System.put_env("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED", "from_env")
36+
37+
{:ok, props} = PropertiesParser.read_property_sources()
38+
39+
# Env should win over any file-based setting
40+
assert props["ryuk.container.privileged"] == "from_env"
41+
42+
# Cleanup
43+
System.delete_env("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED")
44+
end
45+
end
46+
47+
describe "read_property_file/0" do
48+
test "defaults to user file path" do
49+
{:ok, props} = PropertiesParser.read_property_file()
50+
51+
# Should return a map (empty if user file doesn't exist)
52+
assert is_map(props)
53+
end
54+
end
55+
56+
describe "read_property_file/1" do
57+
test "reads properties from specified file" do
58+
{:ok, props} = PropertiesParser.read_property_file("test/fixtures/.testcontainers.properties")
59+
60+
assert is_map(props)
61+
assert props["tc.host"] == "tcp://localhost:9999"
62+
end
63+
64+
test "returns empty map for nonexistent file" do
65+
{:ok, props} = PropertiesParser.read_property_file("/nonexistent/path/.testcontainers.properties")
66+
67+
assert props == %{}
68+
end
69+
end
70+
end

0 commit comments

Comments
 (0)