proteus-config 0.2.0__py3-none-any.whl

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.
proteus/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """
2
+ Proteus — Unified configuration management and translation library.
3
+
4
+ Design patterns implemented:
5
+ - **Optional Singleton**: ``ConfigurationManager.instance()``
6
+ (shared global instance when needed)
7
+ - **Facade**: ``ConfigurationManager`` (simplified public API)
8
+ - **Factory Method**: ``FormatCreator`` (reader/writer pair creation)
9
+ - **Template Method**: ``BaseReader`` / ``BaseWriter`` (fixed algorithms)
10
+ - **Adapter**: ``BaseAdapter`` (wraps external libraries)
11
+
12
+ Quick start::
13
+
14
+ from proteus import ConfigurationManager
15
+
16
+ config = ConfigurationManager()
17
+ config.load("settings.yaml")
18
+ print(config.get("database.host"))
19
+ config.translate("settings.yaml", "settings.json")
20
+ with ConfigurationManager.temporary() as temp:
21
+ temp.load("settings.yaml")
22
+ """
23
+
24
+ from .adapters.base import BaseAdapter
25
+ from .core import ConfigurationManager
26
+ from .exceptions import (
27
+ ConfigurationError,
28
+ ConfigurationNotLoadedError,
29
+ InvalidKeyError,
30
+ UnsupportedFormatError,
31
+ )
32
+ from .formats.base_format import FormatCreator
33
+ from .readers.base import BaseReader
34
+ from .writers.base import BaseWriter
35
+
36
+ __version__ = "0.1.0"
37
+
38
+ __all__ = [
39
+ "ConfigurationManager",
40
+ "FormatCreator",
41
+ "BaseReader",
42
+ "BaseWriter",
43
+ "BaseAdapter",
44
+ "ConfigurationError",
45
+ "UnsupportedFormatError",
46
+ "InvalidKeyError",
47
+ "ConfigurationNotLoadedError",
48
+ ]
@@ -0,0 +1,20 @@
1
+ """
2
+ Adapter layer — wraps external libraries behind the uniform BaseAdapter interface.
3
+
4
+ Adapter pattern (GoF):
5
+ Target → BaseAdapter
6
+ Adapter → JSONAdapter, YAMLAdapter, EnvAdapter
7
+ Adaptee → json (stdlib), yaml (PyYAML), dotenv (python-dotenv)
8
+ """
9
+
10
+ from .base import BaseAdapter
11
+ from .env_adapter import EnvAdapter
12
+ from .json_adapter import JSONAdapter
13
+ from .yaml_adapter import YAMLAdapter
14
+
15
+ __all__ = [
16
+ "BaseAdapter",
17
+ "JSONAdapter",
18
+ "YAMLAdapter",
19
+ "EnvAdapter",
20
+ ]
@@ -0,0 +1,57 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, Dict
3
+
4
+
5
+ class BaseAdapter(ABC):
6
+ """
7
+ Adapter pattern (GoF) — Target interface.
8
+
9
+ Defines the uniform contract that all concrete adapters must fulfill.
10
+ Readers and Writers depend exclusively on this abstraction, never on
11
+ external libraries (Adaptees) directly. Swapping a library requires
12
+ changes only inside the concrete adapter, nowhere else.
13
+
14
+ GoF roles:
15
+ Target → BaseAdapter (this class)
16
+ Adapter → JSONAdapter, YAMLAdapter, EnvAdapter, ...
17
+ Adaptee → json, yaml, dotenv, ... (external libraries)
18
+
19
+ Internal representation (IR):
20
+ Every load() call returns Dict[str, Any].
21
+ Every dump() call accepts Dict[str, Any].
22
+ Semantics depend on the format (e.g. .env is flat while
23
+ JSON/YAML support nesting). Conversion logic lives in the
24
+ concrete adapter, not in the Reader or Writer.
25
+ """
26
+
27
+ @abstractmethod
28
+ def load(self, raw: str) -> Dict[str, Any]:
29
+ """
30
+ Parse a raw string in the format-specific encoding into the IR.
31
+
32
+ Args:
33
+ raw: Full text content of the configuration file.
34
+
35
+ Returns:
36
+ A Dict[str, Any] representing the parsed configuration.
37
+
38
+ Raises:
39
+ ValueError: If the raw content cannot be parsed.
40
+ """
41
+ pass
42
+
43
+ @abstractmethod
44
+ def dump(self, data: Dict[str, Any]) -> str:
45
+ """
46
+ Serialize the IR (a plain Python dict) into a format-specific string.
47
+
48
+ Args:
49
+ data: Configuration data as a plain Python dict.
50
+
51
+ Returns:
52
+ A string ready to be written to disk.
53
+
54
+ Raises:
55
+ ValueError: If the data cannot be serialized.
56
+ """
57
+ pass
@@ -0,0 +1,121 @@
1
+ """
2
+ Adapter pattern (GoF) — Concrete adapter for .env files.
3
+
4
+ Adaptee: python-dotenv (dotenv.dotenv_values)
5
+ Target: BaseAdapter
6
+
7
+ .env format: flat KEY=value pairs, one entry per line.
8
+ Supports comments (#), single/double quotes, and the export prefix.
9
+
10
+ IR: Dict[str, str] — all values are strings (inherent to the format).
11
+ Keys preserve the original casing from the file.
12
+ Convention: keys are typically UPPER_CASE.
13
+ """
14
+
15
+ import io
16
+ from typing import Any, Dict
17
+
18
+ from dotenv import dotenv_values
19
+
20
+ from .base import BaseAdapter
21
+
22
+
23
+ class EnvAdapter(BaseAdapter):
24
+ """
25
+ Wraps python-dotenv (Adaptee) behind the BaseAdapter interface (Target).
26
+
27
+ Note on the IR for .env files:
28
+ The .env format is inherently flat: it does not support nesting.
29
+ The resulting IR is therefore Dict[str, str] with all keys and
30
+ values as plain strings.
31
+
32
+ When dump() receives a nested dict (e.g. translated from JSON),
33
+ nested keys are flattened using "__" as separator:
34
+ {"database": {"host": "localhost"}} → DATABASE__HOST=localhost
35
+
36
+ GoF roles:
37
+ Target → BaseAdapter
38
+ Adapter → EnvAdapter (this class)
39
+ Adaptee → dotenv.dotenv_values (python-dotenv)
40
+ """
41
+
42
+ def load(self, raw: str) -> Dict[str, Any]:
43
+ """
44
+ Delegate to dotenv_values(stream=...) and return the IR dict.
45
+
46
+ Args:
47
+ raw: Full text content of a .env file.
48
+
49
+ Returns:
50
+ Dict[str, str] with the KEY=value pairs from the file.
51
+ Keys with no value (e.g. KEY=) are mapped to an empty string.
52
+
53
+ Raises:
54
+ ValueError: If raw cannot be parsed as a .env file.
55
+ """
56
+ try:
57
+ parsed = dotenv_values(stream=io.StringIO(raw))
58
+ except Exception as exc:
59
+ raise ValueError(f"Invalid .env content: {exc}") from exc
60
+
61
+ # dotenv_values may return None for keys with no assigned value
62
+ return {k: (v if v is not None else "") for k, v in parsed.items()}
63
+
64
+ def dump(self, data: Dict[str, Any]) -> str:
65
+ """
66
+ Serialize a Dict (IR) into .env KEY=value format.
67
+
68
+ Nested dicts are flattened using "__" as separator:
69
+ {'database': {'host': 'localhost'}} → DATABASE__HOST=localhost
70
+
71
+ Non-string values are converted via str().
72
+ Values containing spaces or special characters are automatically
73
+ wrapped in double quotes.
74
+
75
+ Args:
76
+ data: Configuration dict (IR).
77
+
78
+ Returns:
79
+ String in .env format.
80
+ """
81
+ flat = self._flatten(data)
82
+ lines = []
83
+ for key, value in flat.items():
84
+ str_value = str(value)
85
+ # .env uses double quotes; quote manually instead of shlex.quote
86
+ if self._needs_quoting(str_value):
87
+ str_value = '"' + str_value.replace('"', '\\"') + '"'
88
+ lines.append(f"{key}={str_value}")
89
+ return "\n".join(lines) + ("\n" if lines else "")
90
+
91
+ # ------------------------------------------------------------------ #
92
+ # Private helpers #
93
+ # ------------------------------------------------------------------ #
94
+
95
+ def _flatten(
96
+ self,
97
+ data: Dict[str, Any],
98
+ prefix: str = "",
99
+ ) -> Dict[str, str]:
100
+ """
101
+ Recursively flatten a nested dict into a flat string dict.
102
+
103
+ {'db': {'host': 'localhost', 'port': 5432}, 'debug': True}
104
+ → {'DB__HOST': 'localhost', 'DB__PORT': '5432', 'DEBUG': 'True'}
105
+ """
106
+ result: Dict[str, str] = {}
107
+ for key, value in data.items():
108
+ full_key = f"{prefix}__{key}".upper() if prefix else key.upper()
109
+ if isinstance(value, dict):
110
+ result.update(self._flatten(value, prefix=full_key))
111
+ else:
112
+ result[full_key] = str(value)
113
+ return result
114
+
115
+ @staticmethod
116
+ def _needs_quoting(value: str) -> bool:
117
+ """Return True if the value must be wrapped in double quotes."""
118
+ # Quoting required for: empty string, spaces, =, #, $, newlines
119
+ return value == "" or any(
120
+ c in value for c in (" ", "\t", "=", "#", "$", "\n", "'", '"')
121
+ )
@@ -0,0 +1,67 @@
1
+ """
2
+ Adapter pattern (GoF) — Concrete adapter for JSON.
3
+
4
+ Adaptee: json (Python standard library)
5
+ Target: BaseAdapter
6
+ """
7
+
8
+ import json
9
+ from typing import Any, Dict
10
+
11
+ from .base import BaseAdapter
12
+
13
+
14
+ class JSONAdapter(BaseAdapter):
15
+ """
16
+ Wraps the json stdlib module (Adaptee) behind the BaseAdapter interface (Target).
17
+
18
+ Shared by JSONReader and JSONWriter: replacing the underlying library
19
+ (e.g. switching to orjson) requires changes only here.
20
+
21
+ GoF roles:
22
+ Target → BaseAdapter
23
+ Adapter → JSONAdapter (this class)
24
+ Adaptee → json (stdlib) json.loads / json.dumps
25
+ """
26
+
27
+ def load(self, raw: str) -> Dict[str, Any]:
28
+ """
29
+ Delegate to json.loads() and return the IR dict.
30
+
31
+ Args:
32
+ raw: JSON-encoded string.
33
+
34
+ Returns:
35
+ Dict representing the parsed configuration.
36
+
37
+ Raises:
38
+ ValueError: If raw is not valid JSON or the root is not an object.
39
+ """
40
+ try:
41
+ result = json.loads(raw)
42
+ except json.JSONDecodeError as exc:
43
+ raise ValueError(f"Invalid JSON content: {exc}") from exc
44
+
45
+ if not isinstance(result, dict):
46
+ raise ValueError(
47
+ f"JSON document root must be an object, got: {type(result).__name__}"
48
+ )
49
+ return result
50
+
51
+ def dump(self, data: Dict[str, Any]) -> str:
52
+ """
53
+ Delegate to json.dumps() and return the JSON string.
54
+
55
+ Args:
56
+ data: Configuration dict (IR).
57
+
58
+ Returns:
59
+ Pretty-printed JSON string (2-space indent, unicode preserved).
60
+
61
+ Raises:
62
+ ValueError: If data cannot be serialized to JSON.
63
+ """
64
+ try:
65
+ return json.dumps(data, indent=2, ensure_ascii=False)
66
+ except (TypeError, ValueError) as exc:
67
+ raise ValueError(f"Cannot serialize to JSON: {exc}") from exc
@@ -0,0 +1,59 @@
1
+ import sys
2
+ from typing import Any, Dict, cast
3
+
4
+ from .base import BaseAdapter
5
+
6
+ # Handle TOML parsing based on Python version
7
+ if sys.version_info >= (3, 11):
8
+ import tomllib
9
+ else:
10
+ import tomli as tomllib
11
+
12
+ import tomli_w
13
+
14
+
15
+ class TOMLAdapter(BaseAdapter):
16
+ """
17
+ Adapter for TOML configuration format.
18
+
19
+ Uses the standard library ``tomllib`` (Python 3.11+) or the ``tomli``
20
+ backport for parsing, and ``tomli-w`` for serialization.
21
+ """
22
+
23
+ def load(self, raw: str) -> Dict[str, Any]:
24
+ """
25
+ Parse a TOML string into a dictionary.
26
+
27
+ Args:
28
+ raw: TOML formatted string.
29
+
30
+ Returns:
31
+ A dictionary representation of the TOML data.
32
+
33
+ Raises:
34
+ ValueError: If the TOML is invalid.
35
+ """
36
+ try:
37
+ data = tomllib.loads(raw)
38
+ if not isinstance(data, dict):
39
+ raise ValueError("TOML root must be a dictionary")
40
+ return cast(Dict[str, Any], data)
41
+ except Exception as e:
42
+ raise ValueError(f"Invalid TOML content: {e}") from e
43
+
44
+ def dump(self, data: Dict[str, Any]) -> str:
45
+ """
46
+ Serialize a dictionary into a TOML string.
47
+
48
+ Args:
49
+ data: Dictionary to serialize.
50
+
51
+ Returns:
52
+ A TOML formatted string.
53
+
54
+ Raises:
55
+ TypeError: If data is not a dictionary.
56
+ """
57
+ if not isinstance(data, dict):
58
+ raise TypeError("TOML serialization requires a dictionary at the root")
59
+ return cast(str, tomli_w.dumps(data))
@@ -0,0 +1,79 @@
1
+ """
2
+ Adapter pattern (GoF) — Concrete adapter for YAML.
3
+
4
+ Adaptee: yaml (PyYAML)
5
+ Target: BaseAdapter
6
+ """
7
+
8
+ from typing import Any, Dict
9
+
10
+ import yaml
11
+
12
+ from .base import BaseAdapter
13
+
14
+
15
+ class YAMLAdapter(BaseAdapter):
16
+ """
17
+ Wraps PyYAML (Adaptee) behind the BaseAdapter interface (Target).
18
+
19
+ Uses yaml.safe_load() instead of yaml.load() for security:
20
+ it prevents arbitrary Python object deserialization.
21
+
22
+ Shared by YAMLReader and YAMLWriter.
23
+
24
+ GoF roles:
25
+ Target → BaseAdapter
26
+ Adapter → YAMLAdapter (this class)
27
+ Adaptee → yaml (PyYAML) yaml.safe_load / yaml.dump
28
+ """
29
+
30
+ def load(self, raw: str) -> Dict[str, Any]:
31
+ """
32
+ Delegate to yaml.safe_load() and return the IR dict.
33
+
34
+ Args:
35
+ raw: YAML-encoded string.
36
+
37
+ Returns:
38
+ Dict representing the parsed configuration.
39
+
40
+ Raises:
41
+ ValueError: If raw is not valid YAML or the root is not a mapping.
42
+ """
43
+ try:
44
+ result = yaml.safe_load(raw)
45
+ except yaml.YAMLError as exc:
46
+ raise ValueError(f"Invalid YAML content: {exc}") from exc
47
+
48
+ # Empty file or comments-only → safe_load returns None
49
+ if result is None:
50
+ return {}
51
+
52
+ if not isinstance(result, dict):
53
+ raise ValueError(
54
+ f"YAML document root must be a mapping, got: {type(result).__name__}"
55
+ )
56
+ return result
57
+
58
+ def dump(self, data: Dict[str, Any]) -> str:
59
+ """
60
+ Delegate to yaml.dump() and return the YAML string.
61
+
62
+ Args:
63
+ data: Configuration dict (IR).
64
+
65
+ Returns:
66
+ Human-readable YAML string (block style, unicode preserved).
67
+
68
+ Raises:
69
+ ValueError: If data cannot be serialized to YAML.
70
+ """
71
+ try:
72
+ return yaml.dump(
73
+ data,
74
+ default_flow_style=False,
75
+ allow_unicode=True,
76
+ sort_keys=False,
77
+ )
78
+ except yaml.YAMLError as exc:
79
+ raise ValueError(f"Cannot serialize to YAML: {exc}") from exc