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 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]]