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 +48 -0
- proteus/adapters/__init__.py +20 -0
- proteus/adapters/base.py +57 -0
- proteus/adapters/env_adapter.py +121 -0
- proteus/adapters/json_adapter.py +67 -0
- proteus/adapters/toml_adapter.py +59 -0
- proteus/adapters/yaml_adapter.py +79 -0
- proteus/core.py +347 -0
- proteus/exceptions.py +88 -0
- proteus/formats/__init__.py +22 -0
- proteus/formats/base_format.py +44 -0
- proteus/formats/env_format.py +37 -0
- proteus/formats/json_format.py +37 -0
- proteus/formats/toml_format.py +28 -0
- proteus/formats/yaml_format.py +37 -0
- proteus/py.typed +0 -0
- proteus/readers/__init__.py +19 -0
- proteus/readers/base.py +101 -0
- proteus/readers/env_reader.py +27 -0
- proteus/readers/json_reader.py +27 -0
- proteus/readers/toml_reader.py +27 -0
- proteus/readers/yaml_reader.py +27 -0
- proteus/writers/__init__.py +19 -0
- proteus/writers/base.py +88 -0
- proteus/writers/env_writer.py +27 -0
- proteus/writers/json_writer.py +27 -0
- proteus/writers/toml_writer.py +27 -0
- proteus/writers/yaml_writer.py +27 -0
- proteus_config-0.2.0.dist-info/METADATA +252 -0
- proteus_config-0.2.0.dist-info/RECORD +32 -0
- proteus_config-0.2.0.dist-info/WHEEL +4 -0
- proteus_config-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|
+
]
|
proteus/adapters/base.py
ADDED
|
@@ -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
|