stec 0.1.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.
- stec/__init__.py +27 -0
- stec/config.py +112 -0
- stec/errors.py +42 -0
- stec/evaluator.py +116 -0
- stec/main.py +36 -0
- stec/nodes.py +80 -0
- stec/parser.py +314 -0
- stec-0.1.0.dist-info/METADATA +360 -0
- stec-0.1.0.dist-info/RECORD +11 -0
- stec-0.1.0.dist-info/WHEEL +4 -0
- stec-0.1.0.dist-info/licenses/LICENSE +373 -0
stec/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""stec: typed, expressive configuration in a tiny dependency-free language."""
|
|
2
|
+
|
|
3
|
+
from .config import Stec as _StecClass
|
|
4
|
+
from .errors import (
|
|
5
|
+
StecAlreadyLoadedError,
|
|
6
|
+
StecError,
|
|
7
|
+
StecLoadError,
|
|
8
|
+
StecNameError,
|
|
9
|
+
StecSyntaxError,
|
|
10
|
+
StecTypeError,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
#: Global singleton instance: ``Stec.load(...)`` then read values anywhere.
|
|
14
|
+
Stec = _StecClass()
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Stec",
|
|
20
|
+
"StecAlreadyLoadedError",
|
|
21
|
+
"StecError",
|
|
22
|
+
"StecLoadError",
|
|
23
|
+
"StecNameError",
|
|
24
|
+
"StecSyntaxError",
|
|
25
|
+
"StecTypeError",
|
|
26
|
+
"__version__",
|
|
27
|
+
]
|
stec/config.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The Stec singleton: load once, read everywhere."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from .errors import StecAlreadyLoadedError, StecLoadError
|
|
10
|
+
from .evaluator import evaluate
|
|
11
|
+
from .parser import parse
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Stec:
|
|
15
|
+
"""Singleton registry of exposed configuration values.
|
|
16
|
+
|
|
17
|
+
Calling ``Stec()`` always returns the same shared object. The package
|
|
18
|
+
exports one ready-made instance, so the usual usage is simply::
|
|
19
|
+
|
|
20
|
+
from stec import Stec
|
|
21
|
+
|
|
22
|
+
Stec.load("app.stec")
|
|
23
|
+
|
|
24
|
+
Stec.port # attribute access
|
|
25
|
+
Stec["port"] # item access
|
|
26
|
+
Stec.get("port") # with optional default
|
|
27
|
+
Stec.as_dict() # full snapshot
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
_instance: Optional["Stec"] = None
|
|
31
|
+
_lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
def __new__(cls) -> "Stec":
|
|
34
|
+
with cls._lock:
|
|
35
|
+
if cls._instance is None:
|
|
36
|
+
instance = super().__new__(cls)
|
|
37
|
+
instance._values: dict[str, object] = {}
|
|
38
|
+
instance._loaded_from: Optional[str] = None
|
|
39
|
+
cls._instance = instance
|
|
40
|
+
return cls._instance
|
|
41
|
+
|
|
42
|
+
def load(self, path: str | Path, force: bool = False) -> "Stec":
|
|
43
|
+
"""Parse and evaluate the file at *path* into the singleton.
|
|
44
|
+
|
|
45
|
+
Raises ``StecAlreadyLoadedError`` if the configuration was already
|
|
46
|
+
loaded unless *force* is set, which reloads it in place.
|
|
47
|
+
"""
|
|
48
|
+
with Stec._lock:
|
|
49
|
+
if self._loaded_from is not None and not force:
|
|
50
|
+
raise StecAlreadyLoadedError(
|
|
51
|
+
f"configuration already loaded from '{self._loaded_from}'; "
|
|
52
|
+
"pass force=True to reload"
|
|
53
|
+
)
|
|
54
|
+
source = self._read(path)
|
|
55
|
+
document = parse(source)
|
|
56
|
+
values = evaluate(document)
|
|
57
|
+
self._values = values
|
|
58
|
+
self._loaded_from = str(path)
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def _read(path: str | Path) -> str:
|
|
63
|
+
try:
|
|
64
|
+
return Path(path).read_text(encoding="utf-8")
|
|
65
|
+
except UnicodeDecodeError as error:
|
|
66
|
+
raise StecLoadError(f"cannot decode '{path}': not valid UTF-8") from error
|
|
67
|
+
except OSError as error:
|
|
68
|
+
reason = error.strerror or error.__class__.__name__
|
|
69
|
+
raise StecLoadError(f"cannot read '{path}': {reason}") from error
|
|
70
|
+
|
|
71
|
+
def reset(self) -> None:
|
|
72
|
+
"""Forget the loaded configuration (mainly useful in tests)."""
|
|
73
|
+
with Stec._lock:
|
|
74
|
+
self._values = {}
|
|
75
|
+
self._loaded_from = None
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def is_loaded(self) -> bool:
|
|
79
|
+
"""Whether a configuration file has been loaded."""
|
|
80
|
+
return self._loaded_from is not None
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def loaded_from(self) -> Optional[str]:
|
|
84
|
+
"""Path the configuration was loaded from, if any."""
|
|
85
|
+
return self._loaded_from
|
|
86
|
+
|
|
87
|
+
def get(self, name: str, default: Any = None) -> Any:
|
|
88
|
+
"""Return the exposed value *name*, or *default* if absent."""
|
|
89
|
+
return self._values.get(name, default)
|
|
90
|
+
|
|
91
|
+
def as_dict(self) -> dict[str, object]:
|
|
92
|
+
"""Return a copy of all exposed values."""
|
|
93
|
+
return dict(self._values)
|
|
94
|
+
|
|
95
|
+
def __contains__(self, name: object) -> bool:
|
|
96
|
+
return name in self._values
|
|
97
|
+
|
|
98
|
+
def __getitem__(self, name: str) -> Any:
|
|
99
|
+
try:
|
|
100
|
+
return self._values[name]
|
|
101
|
+
except KeyError:
|
|
102
|
+
raise KeyError(f"stec has no exposed value named {name!r}") from None
|
|
103
|
+
|
|
104
|
+
def __getattr__(self, name: str) -> Any:
|
|
105
|
+
values = self.__dict__.get("_values", {})
|
|
106
|
+
if name in values:
|
|
107
|
+
return values[name]
|
|
108
|
+
raise AttributeError(f"stec has no exposed value named {name!r}")
|
|
109
|
+
|
|
110
|
+
def __repr__(self) -> str:
|
|
111
|
+
keys = sorted(self._values)
|
|
112
|
+
return f"Stec(loaded_from={self._loaded_from!r}, keys={keys!r})"
|
stec/errors.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Custom exceptions raised by stec."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from .nodes import Position
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _format_location(position: Optional[Position]) -> str:
|
|
11
|
+
if position is None:
|
|
12
|
+
return ""
|
|
13
|
+
return f" at line {position.line}, column {position.column}"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StecError(Exception):
|
|
17
|
+
"""Base class for every error raised by stec."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, position: Optional[Position] = None) -> None:
|
|
20
|
+
super().__init__(f"{message}{_format_location(position)}")
|
|
21
|
+
self.message = message
|
|
22
|
+
self.position = position
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StecSyntaxError(StecError):
|
|
26
|
+
"""Raised when the source text cannot be tokenized or parsed."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StecTypeError(StecError):
|
|
30
|
+
"""Raised when a value does not match the type declared on ``expose``."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class StecNameError(StecError):
|
|
34
|
+
"""Raised for undefined variable references or duplicate declarations."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class StecLoadError(StecError):
|
|
38
|
+
"""Raised when the configuration file cannot be read."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class StecAlreadyLoadedError(StecError):
|
|
42
|
+
"""Raised when ``Stec.load`` is called twice without ``force=True``."""
|
stec/evaluator.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Evaluation of parsed stec documents into concrete values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .nodes import (
|
|
6
|
+
Document,
|
|
7
|
+
ExposeDecl,
|
|
8
|
+
Expr,
|
|
9
|
+
Interp,
|
|
10
|
+
Literal,
|
|
11
|
+
Position,
|
|
12
|
+
Ternary,
|
|
13
|
+
Var,
|
|
14
|
+
VarDecl,
|
|
15
|
+
)
|
|
16
|
+
from .errors import StecError, StecNameError, StecTypeError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _infer_type(value: object) -> str:
|
|
20
|
+
if isinstance(value, bool):
|
|
21
|
+
return "bool"
|
|
22
|
+
if isinstance(value, int):
|
|
23
|
+
return "int"
|
|
24
|
+
if isinstance(value, float):
|
|
25
|
+
return "float"
|
|
26
|
+
if isinstance(value, str):
|
|
27
|
+
return "string"
|
|
28
|
+
raise StecError(f"internal error: unsupported value type {type(value).__name__}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _format_value(value: object) -> str:
|
|
32
|
+
"""Render a value for string interpolation."""
|
|
33
|
+
if isinstance(value, bool):
|
|
34
|
+
return "true" if value else "false"
|
|
35
|
+
return str(value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _lookup(name: str, position: Position, environment: dict[str, object]) -> object:
|
|
39
|
+
if name not in environment:
|
|
40
|
+
raise StecNameError(
|
|
41
|
+
f"undefined name {name!r}; it must be declared before it is used",
|
|
42
|
+
position,
|
|
43
|
+
)
|
|
44
|
+
return environment[name]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _eval_expr(expr: Expr, environment: dict[str, object]) -> object:
|
|
48
|
+
if isinstance(expr, Literal):
|
|
49
|
+
return expr.value
|
|
50
|
+
if isinstance(expr, Var):
|
|
51
|
+
return _lookup(expr.name, expr.position, environment)
|
|
52
|
+
if isinstance(expr, Interp):
|
|
53
|
+
return "".join(
|
|
54
|
+
piece
|
|
55
|
+
if isinstance(piece, str)
|
|
56
|
+
else _format_value(_eval_expr(piece, environment))
|
|
57
|
+
for piece in expr.parts
|
|
58
|
+
)
|
|
59
|
+
if isinstance(expr, Ternary):
|
|
60
|
+
condition = _eval_expr(expr.condition, environment)
|
|
61
|
+
if _infer_type(condition) != "bool":
|
|
62
|
+
raise StecTypeError(
|
|
63
|
+
f"ternary condition must be a bool, got "
|
|
64
|
+
f"{_infer_type(condition)} {condition!r}",
|
|
65
|
+
expr.position,
|
|
66
|
+
)
|
|
67
|
+
if condition:
|
|
68
|
+
return _eval_expr(expr.then, environment)
|
|
69
|
+
return _eval_expr(expr.otherwise, environment)
|
|
70
|
+
raise StecError(
|
|
71
|
+
f"internal error: unsupported expression node {type(expr).__name__}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _check_duplicate(
|
|
76
|
+
name: str,
|
|
77
|
+
position: Position,
|
|
78
|
+
environment: dict[str, object],
|
|
79
|
+
) -> None:
|
|
80
|
+
if name in environment:
|
|
81
|
+
raise StecNameError(f"duplicate declaration of {name!r}", position)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def evaluate(document: Document) -> dict[str, object]:
|
|
85
|
+
"""Evaluate a document in order and return the exposed values.
|
|
86
|
+
|
|
87
|
+
``var`` and ``expose`` declarations share one namespace: every declared
|
|
88
|
+
name becomes available to ``$name`` and ``${name}`` references made
|
|
89
|
+
afterwards. Only the exposed values are returned.
|
|
90
|
+
"""
|
|
91
|
+
environment: dict[str, object] = {}
|
|
92
|
+
exposed: dict[str, object] = {}
|
|
93
|
+
for declaration in document:
|
|
94
|
+
if isinstance(declaration, VarDecl):
|
|
95
|
+
_check_duplicate(declaration.name, declaration.position, environment)
|
|
96
|
+
environment[declaration.name] = _eval_expr(
|
|
97
|
+
declaration.value, environment
|
|
98
|
+
)
|
|
99
|
+
elif isinstance(declaration, ExposeDecl):
|
|
100
|
+
_check_duplicate(declaration.name, declaration.position, environment)
|
|
101
|
+
value = _eval_expr(declaration.value, environment)
|
|
102
|
+
actual_type = _infer_type(value)
|
|
103
|
+
if actual_type != declaration.type:
|
|
104
|
+
raise StecTypeError(
|
|
105
|
+
f"cannot expose {actual_type} value {value!r} "
|
|
106
|
+
f"as '{declaration.type}' for '{declaration.name}'",
|
|
107
|
+
declaration.position,
|
|
108
|
+
)
|
|
109
|
+
environment[declaration.name] = value
|
|
110
|
+
exposed[declaration.name] = value
|
|
111
|
+
else:
|
|
112
|
+
raise StecError(
|
|
113
|
+
f"internal error: unsupported declaration node "
|
|
114
|
+
f"{type(declaration).__name__}"
|
|
115
|
+
)
|
|
116
|
+
return exposed
|
stec/main.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Small playground to try the parser, evaluator, and singleton by hand."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import tempfile
|
|
5
|
+
|
|
6
|
+
from stec import Stec
|
|
7
|
+
from stec.evaluator import evaluate
|
|
8
|
+
from stec.parser import parse
|
|
9
|
+
|
|
10
|
+
SOURCE = """
|
|
11
|
+
# runtime switches
|
|
12
|
+
var dev = true # flip for prod
|
|
13
|
+
|
|
14
|
+
expose int port = 8080
|
|
15
|
+
expose string secret = "secreteetoiertoiertoeritoeritoeriotio"
|
|
16
|
+
expose string url = $dev ? "127.0.0.1" : "0.0.0.0"
|
|
17
|
+
expose string api = "${url}:${port}/api"
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main() -> None:
|
|
22
|
+
for declaration in parse(SOURCE):
|
|
23
|
+
print(declaration)
|
|
24
|
+
|
|
25
|
+
print(evaluate(parse(SOURCE)))
|
|
26
|
+
|
|
27
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
28
|
+
path = Path(directory) / "app.stec"
|
|
29
|
+
path.write_text(SOURCE, encoding="utf-8")
|
|
30
|
+
Stec.load(path)
|
|
31
|
+
print(Stec.as_dict())
|
|
32
|
+
print(Stec.api, Stec["port"], Stec.get("secret"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
main()
|
stec/nodes.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Abstract syntax tree nodes for stec documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Union
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Position:
|
|
11
|
+
"""A one-based (line, column) location in the source text."""
|
|
12
|
+
|
|
13
|
+
line: int
|
|
14
|
+
column: int
|
|
15
|
+
|
|
16
|
+
def __str__(self) -> str:
|
|
17
|
+
return f"{self.line}:{self.column}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Literal:
|
|
22
|
+
"""A literal value: int, float, string, or bool."""
|
|
23
|
+
|
|
24
|
+
value: object
|
|
25
|
+
kind: str
|
|
26
|
+
position: Position
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Var:
|
|
31
|
+
"""A reference to a previously declared ``var``."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
position: Position
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Ternary:
|
|
39
|
+
"""A conditional expression: ``condition ? then : otherwise``."""
|
|
40
|
+
|
|
41
|
+
condition: Expr
|
|
42
|
+
then: Expr
|
|
43
|
+
otherwise: Expr
|
|
44
|
+
position: Position
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Interp:
|
|
49
|
+
"""A string containing ``${name}`` interpolations.
|
|
50
|
+
|
|
51
|
+
``parts`` alternates literal string chunks and variable references.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
parts: tuple[Union[str, Var], ...]
|
|
55
|
+
position: Position
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
Expr = Union[Literal, Var, Ternary, Interp]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class VarDecl:
|
|
63
|
+
"""An internal variable declaration: ``var NAME = value``."""
|
|
64
|
+
|
|
65
|
+
name: str
|
|
66
|
+
value: Expr
|
|
67
|
+
position: Position
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class ExposeDecl:
|
|
72
|
+
"""A public, typed configuration value: ``expose TYPE NAME = value``."""
|
|
73
|
+
|
|
74
|
+
name: str
|
|
75
|
+
type: str
|
|
76
|
+
value: Expr
|
|
77
|
+
position: Position
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
Document = list[Union[VarDecl, ExposeDecl]]
|