configaroo 0.2.4__tar.gz → 0.4.0__tar.gz

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.
Files changed (25) hide show
  1. {configaroo-0.2.4/src/configaroo.egg-info → configaroo-0.4.0}/PKG-INFO +1 -1
  2. {configaroo-0.2.4 → configaroo-0.4.0}/pyproject.toml +5 -6
  3. {configaroo-0.2.4 → configaroo-0.4.0}/src/configaroo/__init__.py +1 -1
  4. {configaroo-0.2.4 → configaroo-0.4.0}/src/configaroo/configuration.py +35 -32
  5. {configaroo-0.2.4 → configaroo-0.4.0}/src/configaroo/loaders/__init__.py +6 -1
  6. {configaroo-0.2.4 → configaroo-0.4.0/src/configaroo.egg-info}/PKG-INFO +1 -1
  7. {configaroo-0.2.4 → configaroo-0.4.0}/tests/test_configuration.py +7 -7
  8. {configaroo-0.2.4 → configaroo-0.4.0}/tests/test_environment.py +15 -1
  9. {configaroo-0.2.4 → configaroo-0.4.0}/tests/test_json.py +6 -0
  10. {configaroo-0.2.4 → configaroo-0.4.0}/tests/test_toml.py +6 -0
  11. {configaroo-0.2.4 → configaroo-0.4.0}/LICENSE +0 -0
  12. {configaroo-0.2.4 → configaroo-0.4.0}/README.md +0 -0
  13. {configaroo-0.2.4 → configaroo-0.4.0}/setup.cfg +0 -0
  14. {configaroo-0.2.4 → configaroo-0.4.0}/src/configaroo/exceptions.py +0 -0
  15. {configaroo-0.2.4 → configaroo-0.4.0}/src/configaroo/loaders/json.py +0 -0
  16. {configaroo-0.2.4 → configaroo-0.4.0}/src/configaroo/loaders/toml.py +0 -0
  17. {configaroo-0.2.4 → configaroo-0.4.0}/src/configaroo/py.typed +0 -0
  18. {configaroo-0.2.4 → configaroo-0.4.0}/src/configaroo.egg-info/SOURCES.txt +0 -0
  19. {configaroo-0.2.4 → configaroo-0.4.0}/src/configaroo.egg-info/dependency_links.txt +0 -0
  20. {configaroo-0.2.4 → configaroo-0.4.0}/src/configaroo.egg-info/requires.txt +0 -0
  21. {configaroo-0.2.4 → configaroo-0.4.0}/src/configaroo.egg-info/top_level.txt +0 -0
  22. {configaroo-0.2.4 → configaroo-0.4.0}/tests/test_dynamic.py +0 -0
  23. {configaroo-0.2.4 → configaroo-0.4.0}/tests/test_loaders.py +0 -0
  24. {configaroo-0.2.4 → configaroo-0.4.0}/tests/test_print.py +0 -0
  25. {configaroo-0.2.4 → configaroo-0.4.0}/tests/test_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: configaroo
3
- Version: 0.2.4
3
+ Version: 0.4.0
4
4
  Summary: Bouncy handling of configuration files
5
5
  Author-email: Geir Arne Hjelle <geirarne@gmail.com>
6
6
  Maintainer-email: Geir Arne Hjelle <geirarne@gmail.com>
@@ -36,16 +36,15 @@ changelog = "https://github.com/gahjelle/configaroo/releases"
36
36
 
37
37
  [dependency-groups]
38
38
  build = ["build>=1.2.2.post1", "twine>=6.1.0"]
39
+ ci = ["mypy>=1.17.1", "pytest>=8.4.1", "rich>=14.1.0", "ruff>=0.12.7", "tomli-w>=1.2.0"]
39
40
  dev = [
41
+ { include-group = "ci" },
42
+ { include-group = "test" },
40
43
  "bumpver>=2024.1130",
41
44
  "ipython>=8.36.0",
42
- "mypy>=1.17.1",
43
45
  "pre-commit>=4.2.0",
44
- "pytest>=8.3.5",
45
- "rich>=14.1.0",
46
- "ruff>=0.11.11",
47
- "tomli-w>=1.2.0",
48
46
  ]
47
+ test = ["pytest>=8.4.1", "pytest-cov>=6.2.1", "tomli-w>=1.2.0"]
49
48
 
50
49
 
51
50
  [tool.setuptools.dynamic]
@@ -80,7 +79,7 @@ python_version = "3.11"
80
79
  strict = true
81
80
 
82
81
  [tool.bumpver]
83
- current_version = "v0.2.4"
82
+ current_version = "v0.4.0"
84
83
  version_pattern = "vMAJOR.MINOR.PATCH"
85
84
  commit_message = "bump version {old_version} -> {new_version}"
86
85
  tag_message = "{new_version}"
@@ -15,4 +15,4 @@ __all__ = [
15
15
  "print_configuration",
16
16
  ]
17
17
 
18
- __version__ = "0.2.4"
18
+ __version__ = "0.4.0"
@@ -36,33 +36,20 @@ class Configuration(UserDict[str, Any]):
36
36
  def from_file(
37
37
  cls,
38
38
  file_path: str | Path,
39
+ *,
39
40
  loader: str | None = None,
40
- envs: dict[str, str] | None = None,
41
- env_prefix: str = "",
42
- extra_dynamic: dict[str, Any] | None = None,
41
+ not_exist_ok: bool = False,
43
42
  ) -> Self:
44
- """Read a Configuration from a file."""
45
- config_dict = loaders.from_file(file_path, loader=loader)
46
- return cls(config_dict).initialize(
47
- envs=envs, env_prefix=env_prefix, extra_dynamic=extra_dynamic
48
- )
49
-
50
- def initialize(
51
- self,
52
- envs: dict[str, str] | None = None,
53
- env_prefix: str = "",
54
- extra_dynamic: dict[str, Any] | None = None,
55
- ) -> Self:
56
- """Initialize a configuration.
43
+ """Read a Configuration from a file.
57
44
 
58
- The initialization adds environment variables and parses dynamic values.
45
+ If not_exist_ok is True, then a missing file returns an empty
46
+ configuration. This may be useful if the configuration is potentially
47
+ populated by environment variables.
59
48
  """
60
- self = self if envs is None else self.add_envs(envs, prefix=env_prefix) # noqa: PLW0642
61
- return self.parse_dynamic(extra_dynamic)
62
-
63
- def with_model(self, model: type[ModelT]) -> ModelT:
64
- """Apply a pydantic model to a configuration."""
65
- return self.validate_model(model).convert_model(model)
49
+ config_dict = loaders.from_file(
50
+ file_path, loader=loader, not_exist_ok=not_exist_ok
51
+ )
52
+ return cls(config_dict)
66
53
 
67
54
  def __getitem__(self, key: str) -> Any: # noqa: ANN401
68
55
  """Make sure nested sections have type Configuration."""
@@ -112,8 +99,22 @@ class Configuration(UserDict[str, Any]):
112
99
  cls = type(self)
113
100
  return self | {prefix: cls(self.setdefault(prefix, {})).add(rest, value)}
114
101
 
115
- def add_envs(self, envs: dict[str, str], prefix: str = "") -> Self:
116
- """Add environment variables to configuration."""
102
+ def add_envs(self, envs: dict[str, str] | None = None, prefix: str = "") -> Self:
103
+ """Add environment variables to configuration.
104
+
105
+ If you don't specify which environment variables to read, you'll
106
+ automatically add any that matches a top-level value of the
107
+ configuration.
108
+ """
109
+ if envs is None:
110
+ # Automatically add top-level configuration items
111
+ envs = {
112
+ re.sub(r"\W", "_", key).upper(): key
113
+ for key, value in self.data.items()
114
+ if isinstance(value, str | int | float)
115
+ }
116
+
117
+ # Read environment variables
117
118
  for env, key in envs.items():
118
119
  env_key = f"{prefix}{env}"
119
120
  if env_value := os.getenv(env_key):
@@ -129,7 +130,7 @@ class Configuration(UserDict[str, Any]):
129
130
  cls = type(self)
130
131
  variables = (
131
132
  (self.to_flat_dict() if _include_self else {})
132
- | {"project_path": _find_pyproject_toml()}
133
+ | {"project_path": find_pyproject_toml()}
133
134
  | ({} if extra is None else extra)
134
135
  )
135
136
  parsed = cls(
@@ -158,6 +159,10 @@ class Configuration(UserDict[str, Any]):
158
159
  """Convert data types to match the given model."""
159
160
  return model(**self.data)
160
161
 
162
+ def with_model(self, model: type[ModelT]) -> ModelT:
163
+ """Apply a pydantic model to a configuration."""
164
+ return self.validate_model(model).convert_model(model)
165
+
161
166
  def to_dict(self) -> dict[str, Any]:
162
167
  """Dump the configuration into a Python dictionary."""
163
168
  return {
@@ -196,16 +201,14 @@ def print_configuration(config: Configuration | BaseModel, indent: int = 4) -> N
196
201
  )
197
202
 
198
203
 
199
- def _get_rich_print() -> Callable[[str], None]:
204
+ def _get_rich_print() -> Callable[[str], None]: # pragma: no cover
200
205
  """Initialize a Rich console if Rich is installed, otherwise use built-in print."""
201
206
  try:
202
207
  from rich.console import Console # noqa: PLC0415
203
208
 
204
209
  return Console().print
205
210
  except ImportError:
206
- import builtins # noqa: PLC0415
207
-
208
- return builtins.print
211
+ return print
209
212
 
210
213
 
211
214
  def _print_dict_as_tree(
@@ -228,7 +231,7 @@ def _print_dict_as_tree(
228
231
  _print(" " * current_indent + f"- {key}: {value!r}")
229
232
 
230
233
 
231
- def _find_pyproject_toml(
234
+ def find_pyproject_toml(
232
235
  path: Path | None = None, _file_name: str = "pyproject.toml"
233
236
  ) -> Path:
234
237
  """Find a directory that contains a pyproject.toml file.
@@ -241,7 +244,7 @@ def _find_pyproject_toml(
241
244
  if (path / _file_name).exists() or path == path.parent:
242
245
  return path.resolve()
243
246
 
244
- return _find_pyproject_toml(path.parent, _file_name=_file_name)
247
+ return find_pyproject_toml(path.parent, _file_name=_file_name)
245
248
 
246
249
 
247
250
  def _get_foreign_path() -> Path:
@@ -26,9 +26,14 @@ def loader_names() -> list[str]:
26
26
  return sorted(pyplugs.names(PACKAGE))
27
27
 
28
28
 
29
- def from_file(path: str | Path, loader: str | None = None) -> dict[str, Any]:
29
+ def from_file(
30
+ path: str | Path, *, loader: str | None = None, not_exist_ok: bool = False
31
+ ) -> dict[str, Any]:
30
32
  """Load a file using a loader defined by the suffix if necessary."""
31
33
  path = Path(path)
34
+ if not path.exists() and not_exist_ok:
35
+ return {}
36
+
32
37
  loader = path.suffix.lstrip(".") if loader is None else loader
33
38
  try:
34
39
  return load(loader, path=path)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: configaroo
3
- Version: 0.2.4
3
+ Version: 0.4.0
4
4
  Summary: Bouncy handling of configuration files
5
5
  Author-email: Geir Arne Hjelle <geirarne@gmail.com>
6
6
  Maintainer-email: Geir Arne Hjelle <geirarne@gmail.com>
@@ -8,12 +8,6 @@ import configaroo
8
8
  from configaroo import Configuration, configuration
9
9
 
10
10
 
11
- @pytest.fixture
12
- def file_path() -> Path:
13
- """Return the path to the current file."""
14
- return Path(__file__).resolve()
15
-
16
-
17
11
  def test_read_simple_values_as_attributes(config: Configuration) -> None:
18
12
  """Test attribute access for simple values."""
19
13
  assert config.number == 42
@@ -64,6 +58,12 @@ def test_get_nested_values(config: Configuration) -> None:
64
58
  assert config.get("with_dot.org.num") == 1234
65
59
 
66
60
 
61
+ def test_get_with_default(config: Configuration) -> None:
62
+ """Test that .get() falls back on default if the key doesn't exist."""
63
+ assert config.get("word", default="kangaroo") == "platypus"
64
+ assert config.get("another word", default="kangaroo") == "kangaroo"
65
+
66
+
67
67
  def test_update_preserves_type(config: Configuration) -> None:
68
68
  """Test that an update operation gives a Configuration."""
69
69
  assert isinstance(config | {"new": 1}, Configuration)
@@ -122,7 +122,7 @@ def test_contains_with_dotted_key(config: Configuration) -> None:
122
122
 
123
123
  def test_find_pyproject_toml() -> None:
124
124
  """Test that the pyproject.toml file can be located."""
125
- assert configuration._find_pyproject_toml() == Path(__file__).parent.parent
125
+ assert configuration.find_pyproject_toml() == Path(__file__).parent.parent
126
126
 
127
127
 
128
128
  def test_find_foreign_caller() -> None:
@@ -45,7 +45,7 @@ def test_missing_env_ok_if_optional(config: Configuration) -> None:
45
45
 
46
46
 
47
47
  def test_env_prefix(config: Configuration, monkeypatch: pytest.MonkeyPatch) -> None:
48
- """Test that a common prefix can be used for environment_variables."""
48
+ """Test that a common prefix can be used for environment variables."""
49
49
  monkeypatch.setenv("EXAMPLE_NUMBER", "14")
50
50
  monkeypatch.setenv("EXAMPLE_WORD", "platypus")
51
51
  config_w_env = config.add_envs(
@@ -53,3 +53,17 @@ def test_env_prefix(config: Configuration, monkeypatch: pytest.MonkeyPatch) -> N
53
53
  )
54
54
  assert config_w_env.number == "14"
55
55
  assert config_w_env.nested.word == "platypus"
56
+
57
+
58
+ def test_env_automatic(config: Configuration, monkeypatch: pytest.MonkeyPatch) -> None:
59
+ """Test that top-level keys can be automatically filled by env variables."""
60
+ monkeypatch.setenv("NUMBER", "28")
61
+ monkeypatch.setenv("WORD", "kangaroo")
62
+ monkeypatch.setenv("A_B_D_KEY_", "works")
63
+ monkeypatch.setenv("NESTED", "should not be replaced")
64
+ config_w_env = (config | {"A b@d-key!": ""}).add_envs()
65
+
66
+ assert config_w_env.number == "28"
67
+ assert config_w_env.word == "kangaroo"
68
+ assert config_w_env["A b@d-key!"] == "works"
69
+ assert config_w_env.nested != "should not be replaced"
@@ -38,6 +38,12 @@ def test_error_on_wrong_format(toml_path: Path) -> None:
38
38
  Configuration.from_file(toml_path, loader="json")
39
39
 
40
40
 
41
+ def test_file_may_be_allowed_to_not_exist() -> None:
42
+ """Test that not_exist_ok can suppress error when file doesn't exist."""
43
+ config = Configuration.from_file("non-existent.json", not_exist_ok=True)
44
+ assert config.data == {}
45
+
46
+
41
47
  def test_can_read_json_values(json_path: Path) -> None:
42
48
  """Test that values can be accessed."""
43
49
  config = Configuration.from_file(json_path)
@@ -38,6 +38,12 @@ def test_error_on_wrong_format(json_path: Path) -> None:
38
38
  Configuration.from_file(json_path, loader="toml")
39
39
 
40
40
 
41
+ def test_file_may_be_allowed_to_not_exist() -> None:
42
+ """Test that not_exist_ok can suppress error when file doesn't exist."""
43
+ config = Configuration.from_file("non-existent.toml", not_exist_ok=True)
44
+ assert config.data == {}
45
+
46
+
41
47
  def test_can_read_toml_values(toml_path: Path) -> None:
42
48
  """Test that values can be accessed."""
43
49
  config = Configuration.from_file(toml_path)
File without changes
File without changes
File without changes