Skip to content

Commit a3e230d

Browse files
authored
Env parser (#42)
* add parser and few tests.
1 parent fa72ffd commit a3e230d

File tree

3 files changed

+275
-1
lines changed

3 files changed

+275
-1
lines changed

src/confkit/data_types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def _strip_comment(value: str) -> str:
101101
Since hex values use 0x prefix (not #), we can safely strip everything after #.
102102
"""
103103
if "#" in value:
104-
return value.split("#")[0].strip()
104+
return value.split("#", maxsplit=1)[0].strip()
105105
return value
106106

107107
@abstractmethod

src/confkit/ext/parsers.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import os
56
import sys
67
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar
78

@@ -58,6 +59,98 @@ def get(self, section: str, option: str, fallback: str = UNSET) -> str:
5859
def set(self, section: str, option: str, value: str) -> None:
5960
"""Set the value of an option within a section."""
6061

62+
class EnvParser(ConfkitParser):
63+
"""A parser for environment variables and .env files.
64+
65+
This parser operates without sections - all configuration is stored as flat key-value pairs.
66+
Values are read from environment variables and optionally persisted to a .env file.
67+
"""
68+
69+
def __init__(self) -> None: # noqa: D107
70+
self.data: dict[str, str] = {}
71+
72+
@override
73+
def read(self, file: Path) -> None:
74+
"""Precedence, from lowest to highest.
75+
76+
- config file
77+
- environment vars
78+
"""
79+
self.data = dict(os.environ)
80+
81+
if not file.exists():
82+
return
83+
84+
with file.open("r", encoding="utf-8") as f:
85+
for i in f:
86+
line = i.strip()
87+
if not line or line.startswith("#"):
88+
continue
89+
90+
match line.split("=", 1):
91+
case [key, value]:
92+
if key not in os.environ:
93+
# Strip quotes from values
94+
value = value.strip()
95+
if (value.startswith('"') and value.endswith('"')) or \
96+
(value.startswith("'") and value.endswith("'")):
97+
value = value[1:-1]
98+
self.data[key.strip()] = value
99+
100+
@override
101+
def remove_option(self, section: str, option: str) -> None:
102+
"""Remove an option (section is ignored)."""
103+
if option in self.data:
104+
del self.data[option]
105+
106+
@override
107+
def get(self, section: str, option: str, fallback: str = UNSET) -> str:
108+
"""Get the value of an option (section is ignored)."""
109+
if option in self.data:
110+
return self.data[option]
111+
if fallback is not UNSET:
112+
return str(fallback)
113+
return ""
114+
115+
@override
116+
def has_option(self, section: str, option: str) -> bool:
117+
"""Check if an option exists (section is ignored)."""
118+
return option in self.data
119+
120+
@override
121+
def has_section(self, section: str) -> bool:
122+
"""EnvParser has no sections, always returns True for compatibility."""
123+
return True
124+
125+
@override
126+
def write(self, io: TextIOWrapper[_WrappedBuffer]) -> None:
127+
"""Write configuration to a .env file."""
128+
msg = "EnvParser does not support writing to .env"
129+
raise NotImplementedError(msg)
130+
131+
@override
132+
def set_section(self, section: str) -> None:
133+
"""EnvParser has no sections, this is a no-op."""
134+
pass # noqa: PIE790
135+
136+
@override
137+
def set_option(self, option: str) -> None:
138+
"""Set an option (not used in EnvParser)."""
139+
msg = "EnvParser does not support set_option"
140+
raise NotImplementedError(msg)
141+
142+
@override
143+
def add_section(self, section: str) -> None:
144+
"""EnvParser has no sections, this is a no-op."""
145+
pass # noqa: PIE790
146+
147+
@override
148+
def set(self, section: str, option: str, value: str) -> None:
149+
"""Set the value of an option (section is ignored)."""
150+
msg = "EnvParser does not support set"
151+
raise NotImplementedError(msg)
152+
153+
61154

62155
class MsgspecParser(ConfkitParser, Generic[T]):
63156
"""Unified msgspec-based parser for YAML, JSON, TOML configuration files."""

tests/test_env_parser.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""Tests for EnvParser."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
from typing import TYPE_CHECKING
7+
8+
import pytest
9+
10+
from confkit.ext.parsers import EnvParser
11+
12+
if TYPE_CHECKING:
13+
from pathlib import Path
14+
15+
16+
@pytest.fixture
17+
def env_parser() -> EnvParser:
18+
"""Create an EnvParser instance."""
19+
return EnvParser()
20+
21+
22+
@pytest.fixture
23+
def temp_env_file(tmp_path: Path) -> Path:
24+
"""Create a temporary .env file."""
25+
return tmp_path / ".env"
26+
27+
28+
def test_env_parser_init(env_parser: EnvParser) -> None:
29+
"""Test EnvParser initialization."""
30+
assert isinstance(env_parser.data, dict)
31+
32+
33+
def test_env_parser_read_nonexistent_file(env_parser: EnvParser, temp_env_file: Path) -> None:
34+
"""Test reading from a nonexistent file loads environment variables."""
35+
# Set a test env var
36+
test_key = "TEST_CONFKIT_VAR"
37+
test_value = "test_value"
38+
os.environ[test_key] = test_value
39+
40+
try:
41+
env_parser.read(temp_env_file)
42+
assert test_key in env_parser.data
43+
assert env_parser.data[test_key] == test_value
44+
finally:
45+
del os.environ[test_key]
46+
47+
48+
def test_env_parser_read_env_file(env_parser: EnvParser, temp_env_file: Path) -> None:
49+
"""Test reading from a .env file."""
50+
# Create a .env file
51+
temp_env_file.write_text("KEY1=value1\nKEY2=value2\nKEY3=value with spaces\n")
52+
53+
env_parser.read(temp_env_file)
54+
55+
assert "KEY1" in env_parser.data
56+
assert env_parser.data["KEY1"] == "value1"
57+
assert env_parser.data["KEY2"] == "value2"
58+
assert env_parser.data["KEY3"] == "value with spaces"
59+
60+
61+
def test_env_parser_read_env_file_with_comments(env_parser: EnvParser, temp_env_file: Path) -> None:
62+
"""Test reading .env file with comments and empty lines."""
63+
content = """
64+
# This is a comment
65+
KEY1=value1
66+
67+
# Another comment
68+
KEY2=value2
69+
"""
70+
temp_env_file.write_text(content)
71+
72+
env_parser.read(temp_env_file)
73+
74+
assert "KEY1" in env_parser.data
75+
assert "KEY2" in env_parser.data
76+
assert env_parser.data["KEY1"] == "value1"
77+
assert env_parser.data["KEY2"] == "value2"
78+
79+
80+
def test_env_parser_read_env_file_with_quotes(env_parser: EnvParser, temp_env_file: Path) -> None:
81+
"""Test reading .env file with quoted values."""
82+
content = 'KEY1="quoted value"\nKEY2=\'single quoted\'\nKEY3=unquoted\n'
83+
temp_env_file.write_text(content)
84+
85+
env_parser.read(temp_env_file)
86+
87+
assert env_parser.data["KEY1"] == "quoted value"
88+
assert env_parser.data["KEY2"] == "single quoted"
89+
assert env_parser.data["KEY3"] == "unquoted"
90+
91+
92+
def test_env_parser_env_vars_take_precedence(env_parser: EnvParser, temp_env_file: Path) -> None:
93+
"""Test that environment variables take precedence over .env file."""
94+
# Create a .env file
95+
temp_env_file.write_text("CONFKIT_TEST=from_file\n")
96+
97+
# Set environment variable with same key
98+
os.environ["CONFKIT_TEST"] = "from_env"
99+
100+
try:
101+
env_parser.read(temp_env_file)
102+
assert env_parser.data["CONFKIT_TEST"] == "from_env"
103+
finally:
104+
del os.environ["CONFKIT_TEST"]
105+
106+
107+
def test_env_parser_write(env_parser: EnvParser, temp_env_file: Path) -> None:
108+
"""Test that writing raises NotImplementedError for readonly parser."""
109+
env_parser.data = {
110+
"KEY1": "value1",
111+
"KEY2": "value2",
112+
"KEY3": "value with spaces",
113+
}
114+
115+
with temp_env_file.open("w") as f, pytest.raises(NotImplementedError):
116+
env_parser.write(f)
117+
118+
119+
def test_env_parser_has_section(env_parser: EnvParser) -> None:
120+
"""Test has_section always returns True."""
121+
assert env_parser.has_section("any_section")
122+
assert env_parser.has_section("")
123+
124+
125+
def test_env_parser_has_option(env_parser: EnvParser) -> None:
126+
"""Test has_option checks for key existence."""
127+
env_parser.data = {"KEY1": "value1"}
128+
129+
assert env_parser.has_option("any_section", "KEY1")
130+
assert not env_parser.has_option("any_section", "KEY2")
131+
132+
133+
def test_env_parser_get(env_parser: EnvParser) -> None:
134+
"""Test getting values."""
135+
env_parser.data = {"KEY1": "value1"}
136+
137+
assert env_parser.get("any_section", "KEY1") == "value1"
138+
assert env_parser.get("any_section", "KEY2") == ""
139+
assert env_parser.get("any_section", "KEY2", fallback="default") == "default"
140+
141+
142+
def test_env_parser_set(env_parser: EnvParser) -> None:
143+
"""Test that setting values raises NotImplementedError for readonly parser."""
144+
with pytest.raises(NotImplementedError):
145+
env_parser.set("any_section", "KEY1", "value1")
146+
147+
148+
def test_env_parser_remove_option(env_parser: EnvParser) -> None:
149+
"""Test removing options."""
150+
env_parser.data = {"KEY1": "value1", "KEY2": "value2"}
151+
152+
env_parser.remove_option("any_section", "KEY1")
153+
154+
assert "KEY1" not in env_parser.data
155+
assert "KEY2" in env_parser.data
156+
157+
158+
def test_env_parser_add_section(env_parser: EnvParser) -> None:
159+
"""Test add_section is a no-op."""
160+
env_parser.add_section("section") # Should not raise
161+
162+
163+
def test_env_parser_set_section(env_parser: EnvParser) -> None:
164+
"""Test set_section is a no-op."""
165+
env_parser.set_section("section") # Should not raise
166+
167+
168+
def test_env_parser_integration(env_parser: EnvParser, temp_env_file: Path) -> None:
169+
"""Test full read cycle for readonly parser."""
170+
# Create a .env file
171+
content = """DATABASE_URL=postgresql://localhost/db
172+
API_KEY=secret123
173+
DEBUG=true"""
174+
temp_env_file.write_text(content)
175+
176+
# Read from file
177+
env_parser.read(temp_env_file)
178+
179+
assert env_parser.data["DATABASE_URL"] == "postgresql://localhost/db"
180+
assert env_parser.data["API_KEY"] == "secret123"
181+
assert env_parser.data["DEBUG"] == "true"

0 commit comments

Comments
 (0)