Skip to content

Commit 2f41fb2

Browse files
committed
Update dependencies and add YAML, INI and dotenv configuration support
1 parent 9e0e11a commit 2f41fb2

File tree

9 files changed

+207
-7
lines changed

9 files changed

+207
-7
lines changed

.github/workflows/OnPush.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
run: |
2727
python -m pip install --upgrade pip
2828
pip install pytest build Coverage
29-
pip install tomlkit==0.13.2 jsonschema==4.25.1 requests==2.32.5
29+
pip install tomlkit>=0.13.2 jsonschema>=4.25.1 requests>=2.32.5 PyYAML>=6.0.3
3030
3131
- name: Install make
3232
run: sudo apt-get install make

.github/workflows/OnRelease.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
run: |
2626
python -m pip install --upgrade pip
2727
pip install pytest coverage
28-
pip install tomlkit==0.13.2 jsonschema==4.25.1 requests==2.32.5
28+
pip install tomlkit>=0.13.2 jsonschema>=4.25.1 requests>=2.32.5 PyYAML>=6.0.3
2929
3030
- name: Install make
3131
run: sudo apt-get install make

config/config/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
from .config import MemoryConfig, JSONConfig, TOMLConfig
1+
from .config import MemoryConfig, JSONConfig, TOMLConfig, INIConfig, EnvConfig, YAMLConfig

config/config/config.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
import os
1414
import tomlkit
1515
from typing import Any, Dict
16+
import configparser
17+
from io import StringIO
18+
import shlex
19+
import yaml
1620

1721
try: # use gamuLogger if available # pragma: no cover
1822
from gamuLogger import Logger
@@ -395,6 +399,98 @@ def _from_string(self, config_string: str) -> None:
395399
"""
396400
self._config = tomlkit.loads(config_string)
397401

402+
403+
class YAMLConfig(FileConfig):
404+
"""
405+
YAML configuration management class.
406+
"""
407+
def _to_string(self) -> str:
408+
"""
409+
String representation of the configuration in YAML format.
410+
"""
411+
return yaml.safe_dump(self._config, sort_keys=False)
412+
413+
def _from_string(self, config_string: str) -> None:
414+
"""
415+
Create a configuration object from a YAML string.
416+
"""
417+
loaded = yaml.safe_load(config_string)
418+
# YAML can produce None for empty documents
419+
self._config = loaded if isinstance(loaded, dict) and loaded is not None else ({} if loaded is None else loaded)
420+
421+
422+
class INIConfig(FileConfig):
423+
"""
424+
INI configuration management class using configparser.
425+
Sections become nested dictionaries.
426+
"""
427+
def _to_string(self) -> str:
428+
parser = configparser.RawConfigParser()
429+
# If there are top-level keys (not nested under a section), put them in DEFAULT
430+
defaults = {}
431+
for k, v in list(self._config.items()):
432+
if isinstance(v, dict):
433+
parser[k] = {str(kk): str(vv) for kk, vv in v.items()}
434+
else:
435+
defaults[k] = str(v)
436+
if defaults:
437+
parser.defaults().update({str(k): str(v) for k, v in defaults.items()})
438+
sio = StringIO()
439+
parser.write(sio)
440+
return sio.getvalue()
441+
442+
def _from_string(self, config_string: str) -> None:
443+
parser = configparser.RawConfigParser()
444+
try:
445+
parser.read_string(config_string)
446+
except configparser.MissingSectionHeaderError:
447+
# Treat top-level key=value pairs by prepending a DEFAULT section
448+
parser.read_string("[DEFAULT]\n" + config_string)
449+
data: Dict[str, Any] = {}
450+
# defaults (top-level keys)
451+
defaults = dict(parser.defaults())
452+
for k, v in defaults.items():
453+
data[k] = v
454+
for section in parser.sections():
455+
items = dict(parser.items(section))
456+
data[section] = items
457+
self._config = data
458+
459+
460+
class EnvConfig(FileConfig):
461+
"""
462+
.env-style configuration (KEY=VALUE per line).
463+
"""
464+
def _to_string(self) -> str:
465+
lines = []
466+
for k, v in self._config.items():
467+
lines.append(f"{k}={v}")
468+
return "\n".join(lines) + ("\n" if lines else "")
469+
470+
def _from_string(self, config_string: str) -> None:
471+
data: Dict[str, Any] = {}
472+
for raw_line in config_string.splitlines():
473+
line = raw_line.strip()
474+
if not line or line.startswith('#') or line.startswith(';'):
475+
continue
476+
# support export VAR=VAL
477+
if line.startswith('export '):
478+
line = line[len('export '):]
479+
if '=' not in line:
480+
continue
481+
key, value = line.split('=', 1)
482+
key = key.strip()
483+
value = value.strip()
484+
# remove surrounding quotes if present
485+
if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
486+
try:
487+
# preserve escaped sequences by using shlex
488+
value = shlex.split(value)[0]
489+
except Exception:
490+
value = value[1:-1]
491+
data[key] = value
492+
self._config = data
493+
398494
class MemoryConfig(BaseConfig):
399495
"""
400496
In-memory configuration management class.
@@ -418,4 +514,10 @@ def get_config(file_path: str) -> FileConfig:
418514
return JSONConfig(file_path)
419515
if file_path.lower().endswith('.toml'):
420516
return TOMLConfig(file_path)
517+
if file_path.lower().endswith(('.yaml', '.yml')):
518+
return YAMLConfig(file_path)
519+
if file_path.lower().endswith(('.ini', '.cfg')):
520+
return INIConfig(file_path)
521+
if file_path.lower().endswith('.env'):
522+
return EnvConfig(file_path)
421523
raise ValueError(f"Unsupported configuration file format: {file_path}")

config/pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ authors = [
1111
]
1212
requires-python = ">=3.10"
1313
dependencies = [
14-
"tomlkit==0.13.2",
15-
"jsonschema==4.25.1",
16-
"requests==2.32.5"
14+
"tomlkit>=0.13.2",
15+
"jsonschema>=4.25.1",
16+
"requests>=2.32.5",
17+
"PyYAML>=6.0.3"
1718
]
1819

1920
[tool.setuptools.packages.find]

config/tests/env_tests.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import os
2+
import tempfile
3+
import pytest
4+
from config.config import EnvConfig
5+
6+
7+
def test_load_env_file(tmp_path):
8+
file_path = tmp_path / ".env"
9+
with open(file_path, "w", encoding="utf-8") as f:
10+
f.write("# comment\nKEY=VALUE\nOTHER='quoted'\nexport NOQUOTES=123\n")
11+
12+
cfg = EnvConfig(str(file_path))
13+
assert cfg._config["KEY"] == "VALUE"
14+
assert cfg._config["OTHER"] == "quoted"
15+
assert cfg._config["NOQUOTES"] == "123"
16+
17+
18+
def test_env_save_and_reload(tmp_path):
19+
file_path = tmp_path / ".env2"
20+
cfg = EnvConfig(str(file_path))
21+
cfg._config = {"A": "1", "B": "two"}
22+
cfg._save()
23+
24+
cfg2 = EnvConfig(str(file_path))
25+
assert cfg2._config == {"A": "1", "B": "two"}

config/tests/ini_tests.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import os
2+
import tempfile
3+
import pytest
4+
from config.config import INIConfig
5+
6+
7+
def test_load_ini_file(tmp_path):
8+
file_path = tmp_path / "test.ini"
9+
with open(file_path, "w", encoding="utf-8") as f:
10+
f.write("[section]\nkey = value\n")
11+
12+
cfg = INIConfig(str(file_path))
13+
assert cfg._config["section"]["key"] == "value"
14+
15+
16+
def test_top_level_ini_pairs(tmp_path):
17+
file_path = tmp_path / "top.ini"
18+
with open(file_path, "w", encoding="utf-8") as f:
19+
f.write("key1=val1\nkey2=val2\n")
20+
21+
cfg = INIConfig(str(file_path))
22+
assert cfg._config["key1"] == "val1"
23+
assert cfg._config["key2"] == "val2"
24+
25+
26+
def test_save_ini_roundtrip(tmp_path):
27+
file_path = tmp_path / "out.ini"
28+
cfg = INIConfig(str(file_path))
29+
cfg._config = {"section": {"a": "1"}, "global": "v"}
30+
cfg._save()
31+
32+
cfg2 = INIConfig(str(file_path))
33+
assert cfg2._config["section"]["a"] == "1"
34+
assert cfg2._config["global"] == "v"

config/tests/yaml_tests.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import os
2+
import json
3+
import tempfile
4+
import pytest
5+
from config.config import YAMLConfig
6+
7+
8+
def test_load_yaml_file(tmp_path):
9+
file_path = tmp_path / "test.yaml"
10+
content = {
11+
"foo": "bar",
12+
"nested": {"a": 1}
13+
}
14+
with open(file_path, "w", encoding="utf-8") as f:
15+
import yaml
16+
yaml.safe_dump(content, f)
17+
18+
cfg = YAMLConfig(str(file_path))
19+
assert cfg._config == content
20+
21+
22+
def test_save_and_reload_yaml(tmp_path):
23+
file_path = tmp_path / "save.yaml"
24+
cfg = YAMLConfig(str(file_path))
25+
cfg._config = {"x": 1, "y": {"z": "v"}}
26+
cfg._save()
27+
28+
cfg2 = YAMLConfig(str(file_path))
29+
assert cfg2._config == {"x": 1, "y": {"z": "v"}}
30+
31+
32+
def test_invalid_yaml_raises(tmp_path):
33+
file_path = tmp_path / "bad.yaml"
34+
with open(file_path, "w", encoding="utf-8") as f:
35+
f.write("- just: [unclosed")
36+
37+
with pytest.raises(Exception):
38+
YAMLConfig(str(file_path))

makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
VERSION = $(shell python get_version.py)
33

4-
DEPENDENCIES_NAMES = jsonschema requests tomlkit coverage pytest
4+
DEPENDENCIES_NAMES = jsonschema requests tomlkit coverage pytest PyYAML
55

66
MODULES = cache colors config http_code version singleton
77

0 commit comments

Comments
 (0)