cnos 1.11.4__tar.gz
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.
- cnos-1.11.4/.gitignore +33 -0
- cnos-1.11.4/PKG-INFO +8 -0
- cnos-1.11.4/pyproject.toml +21 -0
- cnos-1.11.4/src/cnos/__init__.py +181 -0
- cnos-1.11.4/src/cnos/_internal/__init__.py +1 -0
- cnos-1.11.4/src/cnos/_internal/entry.py +31 -0
- cnos-1.11.4/src/cnos/derive.py +399 -0
- cnos-1.11.4/src/cnos/discover.py +49 -0
- cnos-1.11.4/src/cnos/env.py +38 -0
- cnos-1.11.4/src/cnos/errors.py +29 -0
- cnos-1.11.4/src/cnos/exports.py +364 -0
- cnos-1.11.4/src/cnos/graph.py +151 -0
- cnos-1.11.4/src/cnos/inspect.py +132 -0
- cnos-1.11.4/src/cnos/jscompat.py +150 -0
- cnos-1.11.4/src/cnos/loader.py +201 -0
- cnos-1.11.4/src/cnos/projection.py +151 -0
- cnos-1.11.4/src/cnos/runtime.py +1058 -0
- cnos-1.11.4/src/cnos/secrets.py +512 -0
- cnos-1.11.4/src/cnos/types.py +207 -0
- cnos-1.11.4/tests/__init__.py +0 -0
- cnos-1.11.4/tests/test_derive.py +372 -0
- cnos-1.11.4/tests/test_runtime.py +418 -0
- cnos-1.11.4/tests/test_secrets.py +234 -0
cnos-1.11.4/.gitignore
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
.coop/logs/
|
|
2
|
+
.coop/tmp/
|
|
3
|
+
node_modules/
|
|
4
|
+
dist/
|
|
5
|
+
coverage/
|
|
6
|
+
.artifacts/
|
|
7
|
+
.turbo/
|
|
8
|
+
.DS_Store
|
|
9
|
+
Thumbs.db
|
|
10
|
+
.idea/
|
|
11
|
+
.vscode/
|
|
12
|
+
*.log
|
|
13
|
+
.env
|
|
14
|
+
.env.local
|
|
15
|
+
.env.*.local
|
|
16
|
+
!.env.example
|
|
17
|
+
pnpm-debug.log*
|
|
18
|
+
|
|
19
|
+
# Claude Code worktrees and session scratch
|
|
20
|
+
.claude/
|
|
21
|
+
.tmp/
|
|
22
|
+
|
|
23
|
+
# Python bytecode
|
|
24
|
+
__pycache__/
|
|
25
|
+
*.pyc
|
|
26
|
+
*.pyo
|
|
27
|
+
*.pyd
|
|
28
|
+
.pytest_cache/
|
|
29
|
+
|
|
30
|
+
# Java build output
|
|
31
|
+
target/
|
|
32
|
+
*.class
|
|
33
|
+
*.jar
|
cnos-1.11.4/PKG-INFO
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cnos"
|
|
7
|
+
version = "1.11.4"
|
|
8
|
+
description = "CNOS runtime client for Python"
|
|
9
|
+
requires-python = ">=3.8"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"cryptography>=41.0.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
keyring = ["keyring>=24.0.0"]
|
|
16
|
+
|
|
17
|
+
[tool.hatch.build.targets.wheel]
|
|
18
|
+
packages = ["src/cnos"]
|
|
19
|
+
|
|
20
|
+
[tool.pytest.ini_options]
|
|
21
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""CNOS Python runtime — public API.
|
|
2
|
+
|
|
3
|
+
Module-level functions mirror Go's singleton.go delegating functions.
|
|
4
|
+
Call cnos.ready() or cnos.load() before using the module-level read functions.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
# Re-export the main public types
|
|
11
|
+
from cnos.errors import CnosError
|
|
12
|
+
from cnos.exports import (
|
|
13
|
+
config_hash,
|
|
14
|
+
format_message,
|
|
15
|
+
log_message,
|
|
16
|
+
to_env,
|
|
17
|
+
to_namespace,
|
|
18
|
+
to_object,
|
|
19
|
+
to_public_env,
|
|
20
|
+
to_server_projection,
|
|
21
|
+
)
|
|
22
|
+
from cnos.inspect import InspectResult
|
|
23
|
+
from cnos.loader import (
|
|
24
|
+
CnosOptions,
|
|
25
|
+
bootstrap_default_runtime,
|
|
26
|
+
default_runtime,
|
|
27
|
+
load,
|
|
28
|
+
load_projection,
|
|
29
|
+
load_projection_file,
|
|
30
|
+
ready,
|
|
31
|
+
set_default_runtime,
|
|
32
|
+
)
|
|
33
|
+
from cnos.projection import ServerProjection
|
|
34
|
+
from cnos.runtime import CnosRuntime
|
|
35
|
+
from cnos.types import (
|
|
36
|
+
ConfigOrigin,
|
|
37
|
+
DerivedFormula,
|
|
38
|
+
SecretReference,
|
|
39
|
+
SecretVaultProvider,
|
|
40
|
+
SecretVaultProviderFactory,
|
|
41
|
+
ToEnvOptions,
|
|
42
|
+
ToPublicEnvOptions,
|
|
43
|
+
VaultAuthConfig,
|
|
44
|
+
VaultAuthDefinition,
|
|
45
|
+
VaultAuthSource,
|
|
46
|
+
VaultDefinition,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Module-level singleton API — mirror Go's package-level functions
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def read(key: str) -> Tuple[Any, bool]:
|
|
54
|
+
return default_runtime().read(key)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def require(key: str) -> Any:
|
|
58
|
+
return default_runtime().require(key)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read_or(key: str, fallback: Any) -> Any:
|
|
62
|
+
return default_runtime().read_or(key, fallback)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def value(path: str) -> Tuple[Any, bool]:
|
|
66
|
+
return default_runtime().value(path)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def secret(path: str) -> Tuple[Any, bool]:
|
|
70
|
+
return default_runtime().secret(path)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def meta(path: str) -> Tuple[Any, bool]:
|
|
74
|
+
return default_runtime().meta(path)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def public(path: str) -> Tuple[Any, bool]:
|
|
78
|
+
return default_runtime().public(path)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def inspect(key: str) -> InspectResult:
|
|
82
|
+
return default_runtime().inspect(key)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_to_object() -> Dict[str, Any]:
|
|
86
|
+
return to_object(default_runtime())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_to_namespace(namespace: str) -> Dict[str, Any]:
|
|
90
|
+
return to_namespace(default_runtime(), namespace)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_to_env(options: Optional[ToEnvOptions] = None) -> Dict[str, str]:
|
|
94
|
+
return to_env(default_runtime(), options)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_to_public_env(options: Optional[ToPublicEnvOptions] = None) -> Dict[str, str]:
|
|
98
|
+
return to_public_env(default_runtime(), options)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_to_server_projection() -> ServerProjection:
|
|
102
|
+
return to_server_projection(default_runtime())
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_format(message: str) -> str:
|
|
106
|
+
return format_message(default_runtime(), message)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_log(message: str) -> str:
|
|
110
|
+
return log_message(default_runtime(), message)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def refresh_secrets() -> None:
|
|
114
|
+
default_runtime().refresh_secrets()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def refresh_secret(path: str) -> None:
|
|
118
|
+
default_runtime().refresh_secret(path)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def register_runtime_provider(namespace: str, provider: Any) -> None:
|
|
122
|
+
default_runtime().register_runtime_provider(namespace, provider)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def register_secret_vault_providers(*factories: SecretVaultProviderFactory) -> None:
|
|
126
|
+
default_runtime().register_secret_vault_providers(*factories)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# Bootstrap eagerly (mirrors Go's init())
|
|
130
|
+
bootstrap_default_runtime()
|
|
131
|
+
|
|
132
|
+
__all__ = [
|
|
133
|
+
# Error
|
|
134
|
+
"CnosError",
|
|
135
|
+
# Main classes
|
|
136
|
+
"CnosRuntime",
|
|
137
|
+
"CnosOptions",
|
|
138
|
+
"ServerProjection",
|
|
139
|
+
"InspectResult",
|
|
140
|
+
# Types
|
|
141
|
+
"DerivedFormula",
|
|
142
|
+
"SecretReference",
|
|
143
|
+
"VaultDefinition",
|
|
144
|
+
"VaultAuthConfig",
|
|
145
|
+
"VaultAuthDefinition",
|
|
146
|
+
"VaultAuthSource",
|
|
147
|
+
"ConfigOrigin",
|
|
148
|
+
"ToEnvOptions",
|
|
149
|
+
"ToPublicEnvOptions",
|
|
150
|
+
"SecretVaultProvider",
|
|
151
|
+
"SecretVaultProviderFactory",
|
|
152
|
+
# Loader
|
|
153
|
+
"load",
|
|
154
|
+
"load_projection",
|
|
155
|
+
"load_projection_file",
|
|
156
|
+
"ready",
|
|
157
|
+
"set_default_runtime",
|
|
158
|
+
"default_runtime",
|
|
159
|
+
# Module-level read API
|
|
160
|
+
"read",
|
|
161
|
+
"require",
|
|
162
|
+
"read_or",
|
|
163
|
+
"value",
|
|
164
|
+
"secret",
|
|
165
|
+
"meta",
|
|
166
|
+
"public",
|
|
167
|
+
"inspect",
|
|
168
|
+
# Export API
|
|
169
|
+
"get_to_object",
|
|
170
|
+
"get_to_namespace",
|
|
171
|
+
"get_to_env",
|
|
172
|
+
"get_to_public_env",
|
|
173
|
+
"get_to_server_projection",
|
|
174
|
+
"get_format",
|
|
175
|
+
"get_log",
|
|
176
|
+
"config_hash",
|
|
177
|
+
"refresh_secrets",
|
|
178
|
+
"refresh_secret",
|
|
179
|
+
"register_runtime_provider",
|
|
180
|
+
"register_secret_vault_providers",
|
|
181
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# internal package
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Internal runtime entry and provenance types."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, List, Optional
|
|
6
|
+
|
|
7
|
+
from cnos.types import ConfigOrigin, SecretReference
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class RuntimeProvenance:
|
|
12
|
+
source_id: str = ""
|
|
13
|
+
plugin_id: str = ""
|
|
14
|
+
workspace_id: str = ""
|
|
15
|
+
value: Any = None
|
|
16
|
+
origin: Optional[ConfigOrigin] = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class RuntimeEntry:
|
|
21
|
+
key: str = ""
|
|
22
|
+
namespace: str = ""
|
|
23
|
+
value: Any = None
|
|
24
|
+
alias_to: str = ""
|
|
25
|
+
promoted_from: str = ""
|
|
26
|
+
formula: Optional[Any] = None # ParsedFormula — avoid circular import
|
|
27
|
+
formula_cached: bool = False
|
|
28
|
+
formula_cache: Any = None
|
|
29
|
+
secret_ref: Optional[SecretReference] = None
|
|
30
|
+
winner: RuntimeProvenance = field(default_factory=RuntimeProvenance)
|
|
31
|
+
overridden: List[RuntimeProvenance] = field(default_factory=list)
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Derived formula parser and evaluator — mirrors Go's derive.go exactly."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
|
6
|
+
|
|
7
|
+
from cnos.errors import CnosError
|
|
8
|
+
from cnos.jscompat import js_stringify_value, js_strict_equal
|
|
9
|
+
from cnos.types import DerivedFormula
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# AST node
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ExprNode:
|
|
18
|
+
kind: str = "" # "literal" | "ref" | "call"
|
|
19
|
+
value: Any = None # for kind="literal"
|
|
20
|
+
path: str = "" # for kind="ref"
|
|
21
|
+
name: str = "" # for kind="call"
|
|
22
|
+
args: List["ExprNode"] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Parsed formula (internal)
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ParsedFormula:
|
|
31
|
+
raw: str = ""
|
|
32
|
+
refs: List[str] = field(default_factory=list)
|
|
33
|
+
deps: List[str] = field(default_factory=list)
|
|
34
|
+
runtime_refs: List[str] = field(default_factory=list)
|
|
35
|
+
runtime_dependent: bool = False
|
|
36
|
+
ast: Optional[ExprNode] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_DERIVE_BUILTINS: Set[str] = {"concat", "coalesce", "when", "exists", "eq", "ne"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Parser state
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
class _ParserState:
|
|
47
|
+
def __init__(self, source: str) -> None:
|
|
48
|
+
self.source = source
|
|
49
|
+
self.index = 0
|
|
50
|
+
|
|
51
|
+
def errorf(self, message: str) -> CnosError:
|
|
52
|
+
return CnosError(f"cnos: {message} at position {self.index + 1}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_whitespace(ch: str) -> bool:
|
|
56
|
+
return ch in " \n\r\t"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _skip_whitespace(state: _ParserState) -> None:
|
|
60
|
+
while state.index < len(state.source) and _is_whitespace(state.source[state.index]):
|
|
61
|
+
state.index += 1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_identifier_start(ch: str) -> bool:
|
|
65
|
+
return ch.isalpha() or ch == "_"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_identifier_part(ch: str) -> bool:
|
|
69
|
+
return ch.isalpha() or ch.isdigit() or ch in "._-"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_valid_template_ref(value: str) -> bool:
|
|
73
|
+
if not value or not _is_identifier_start(value[0]):
|
|
74
|
+
return False
|
|
75
|
+
for ch in value[1:]:
|
|
76
|
+
if not _is_identifier_part(ch):
|
|
77
|
+
return False
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Parser functions
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def _parse_string_literal(state: _ParserState) -> ExprNode:
|
|
86
|
+
state.index += 1 # consume opening '
|
|
87
|
+
chars: List[str] = []
|
|
88
|
+
while state.index < len(state.source):
|
|
89
|
+
ch = state.source[state.index]
|
|
90
|
+
if ch == "\\":
|
|
91
|
+
if state.index + 1 >= len(state.source):
|
|
92
|
+
raise state.errorf("Unterminated escape sequence")
|
|
93
|
+
chars.append(state.source[state.index + 1])
|
|
94
|
+
state.index += 2
|
|
95
|
+
continue
|
|
96
|
+
if ch == "'":
|
|
97
|
+
state.index += 1
|
|
98
|
+
return ExprNode(kind="literal", value="".join(chars))
|
|
99
|
+
chars.append(ch)
|
|
100
|
+
state.index += 1
|
|
101
|
+
raise state.errorf("Unterminated string literal")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _parse_number_literal(state: _ParserState) -> ExprNode:
|
|
105
|
+
start = state.index
|
|
106
|
+
while state.index < len(state.source) and state.source[state.index].isdigit():
|
|
107
|
+
state.index += 1
|
|
108
|
+
if state.index < len(state.source) and state.source[state.index] == ".":
|
|
109
|
+
state.index += 1
|
|
110
|
+
while state.index < len(state.source) and state.source[state.index].isdigit():
|
|
111
|
+
state.index += 1
|
|
112
|
+
raw = state.source[start:state.index]
|
|
113
|
+
try:
|
|
114
|
+
return ExprNode(kind="literal", value=float(raw))
|
|
115
|
+
except ValueError as exc:
|
|
116
|
+
raise CnosError(f"cnos: invalid number literal: {raw}") from exc
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _parse_identifier(state: _ParserState) -> str:
|
|
120
|
+
if state.index >= len(state.source) or not _is_identifier_start(state.source[state.index]):
|
|
121
|
+
raise state.errorf("Expected identifier")
|
|
122
|
+
start = state.index
|
|
123
|
+
state.index += 1
|
|
124
|
+
while state.index < len(state.source) and _is_identifier_part(state.source[state.index]):
|
|
125
|
+
state.index += 1
|
|
126
|
+
return state.source[start:state.index]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse_arguments(state: _ParserState) -> List[ExprNode]:
|
|
130
|
+
args: List[ExprNode] = []
|
|
131
|
+
_skip_whitespace(state)
|
|
132
|
+
if state.index < len(state.source) and state.source[state.index] == ")":
|
|
133
|
+
state.index += 1
|
|
134
|
+
return args
|
|
135
|
+
while state.index < len(state.source):
|
|
136
|
+
node = _parse_expression_node(state)
|
|
137
|
+
args.append(node)
|
|
138
|
+
_skip_whitespace(state)
|
|
139
|
+
if state.index >= len(state.source):
|
|
140
|
+
break
|
|
141
|
+
ch = state.source[state.index]
|
|
142
|
+
if ch == ",":
|
|
143
|
+
state.index += 1
|
|
144
|
+
_skip_whitespace(state)
|
|
145
|
+
elif ch == ")":
|
|
146
|
+
state.index += 1
|
|
147
|
+
return args
|
|
148
|
+
else:
|
|
149
|
+
raise state.errorf('Expected "," or ")"')
|
|
150
|
+
raise state.errorf("Unterminated function call")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _parse_identifier_or_call(state: _ParserState) -> ExprNode:
|
|
154
|
+
identifier = _parse_identifier(state)
|
|
155
|
+
_skip_whitespace(state)
|
|
156
|
+
if state.index < len(state.source) and state.source[state.index] == "(":
|
|
157
|
+
if identifier not in _DERIVE_BUILTINS:
|
|
158
|
+
raise CnosError(f"cnos: unknown derive function: {identifier}")
|
|
159
|
+
state.index += 1 # consume '('
|
|
160
|
+
args = _parse_arguments(state)
|
|
161
|
+
return ExprNode(kind="call", name=identifier, args=args)
|
|
162
|
+
# keywords or ref
|
|
163
|
+
if identifier == "true":
|
|
164
|
+
return ExprNode(kind="literal", value=True)
|
|
165
|
+
if identifier == "false":
|
|
166
|
+
return ExprNode(kind="literal", value=False)
|
|
167
|
+
if identifier == "null":
|
|
168
|
+
return ExprNode(kind="literal", value=None)
|
|
169
|
+
return ExprNode(kind="ref", path=identifier)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _parse_expression_node(state: _ParserState) -> ExprNode:
|
|
173
|
+
_skip_whitespace(state)
|
|
174
|
+
if state.index >= len(state.source):
|
|
175
|
+
raise state.errorf("Unexpected token")
|
|
176
|
+
ch = state.source[state.index]
|
|
177
|
+
if ch == "'":
|
|
178
|
+
return _parse_string_literal(state)
|
|
179
|
+
if ch.isdigit():
|
|
180
|
+
return _parse_number_literal(state)
|
|
181
|
+
if _is_identifier_start(ch):
|
|
182
|
+
return _parse_identifier_or_call(state)
|
|
183
|
+
raise state.errorf("Unexpected token")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse_template(source: str) -> ExprNode:
|
|
187
|
+
parts: List[ExprNode] = []
|
|
188
|
+
cursor = 0
|
|
189
|
+
while cursor < len(source):
|
|
190
|
+
start = source.find("${", cursor)
|
|
191
|
+
if start == -1:
|
|
192
|
+
if cursor < len(source):
|
|
193
|
+
parts.append(ExprNode(kind="literal", value=source[cursor:]))
|
|
194
|
+
break
|
|
195
|
+
if start > cursor:
|
|
196
|
+
parts.append(ExprNode(kind="literal", value=source[cursor:start]))
|
|
197
|
+
end = source.find("}", start + 2)
|
|
198
|
+
if end == -1:
|
|
199
|
+
raise CnosError(
|
|
200
|
+
f"cnos: invalid derivation template: unclosed ${{...}} at position {start + 1}"
|
|
201
|
+
)
|
|
202
|
+
ref = source[start + 2:end].strip()
|
|
203
|
+
if not ref:
|
|
204
|
+
raise CnosError(
|
|
205
|
+
f"cnos: invalid derivation template: empty reference at position {start + 1}"
|
|
206
|
+
)
|
|
207
|
+
if not _is_valid_template_ref(ref):
|
|
208
|
+
raise CnosError(
|
|
209
|
+
f"cnos: invalid derivation template reference {ref!r}"
|
|
210
|
+
)
|
|
211
|
+
parts.append(ExprNode(kind="ref", path=ref))
|
|
212
|
+
cursor = end + 1
|
|
213
|
+
|
|
214
|
+
if not parts:
|
|
215
|
+
return ExprNode(kind="literal", value="")
|
|
216
|
+
if len(parts) == 1:
|
|
217
|
+
return parts[0]
|
|
218
|
+
return ExprNode(kind="call", name="concat", args=parts)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def parse_derived_source(source: str) -> ExprNode:
|
|
222
|
+
if "${" in source:
|
|
223
|
+
return _parse_template(source)
|
|
224
|
+
state = _ParserState(source)
|
|
225
|
+
node = _parse_expression_node(state)
|
|
226
|
+
_skip_whitespace(state)
|
|
227
|
+
if state.index != len(state.source):
|
|
228
|
+
raise state.errorf("Unexpected trailing input")
|
|
229
|
+
return node
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def parse_derived_formula(formula: DerivedFormula) -> ParsedFormula:
|
|
233
|
+
ast = parse_derived_source(formula.expr)
|
|
234
|
+
refs = list(formula.deps) + list(formula.runtime_refs)
|
|
235
|
+
unique_refs = _unique_sorted(refs)
|
|
236
|
+
return ParsedFormula(
|
|
237
|
+
raw=formula.expr,
|
|
238
|
+
refs=unique_refs,
|
|
239
|
+
deps=list(formula.deps),
|
|
240
|
+
runtime_refs=list(formula.runtime_refs),
|
|
241
|
+
runtime_dependent=len(formula.runtime_refs) > 0,
|
|
242
|
+
ast=ast,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def parse_raw_derived_value(value: Any) -> ParsedFormula:
|
|
247
|
+
"""Parse a $derive value from a graph entry."""
|
|
248
|
+
source = _derive_source_from_value(value)
|
|
249
|
+
ast = parse_derived_source(source)
|
|
250
|
+
refs = _extract_refs(ast, [])
|
|
251
|
+
unique_refs = _unique_sorted(refs)
|
|
252
|
+
return ParsedFormula(
|
|
253
|
+
raw=source,
|
|
254
|
+
refs=unique_refs,
|
|
255
|
+
deps=unique_refs,
|
|
256
|
+
ast=ast,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _derive_source_from_value(value: Any) -> str:
|
|
261
|
+
if not isinstance(value, dict):
|
|
262
|
+
raise CnosError(
|
|
263
|
+
"cnos: derived value requires either a template string or { expr } object"
|
|
264
|
+
)
|
|
265
|
+
raw = value.get("$derive")
|
|
266
|
+
if raw is None:
|
|
267
|
+
raise CnosError(
|
|
268
|
+
"cnos: derived value requires either a template string or { expr } object"
|
|
269
|
+
)
|
|
270
|
+
if isinstance(raw, str):
|
|
271
|
+
return raw
|
|
272
|
+
if isinstance(raw, dict):
|
|
273
|
+
source = raw.get("expr", "")
|
|
274
|
+
if not isinstance(source, str) or not source.strip():
|
|
275
|
+
raise CnosError(
|
|
276
|
+
"cnos: derived value requires either a template string or { expr } object"
|
|
277
|
+
)
|
|
278
|
+
return source
|
|
279
|
+
raise CnosError("cnos: derived value requires either a template string or { expr } object")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def is_derived_value(value: Any) -> bool:
|
|
283
|
+
return isinstance(value, dict) and "$derive" in value
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _extract_refs(node: ExprNode, refs: List[str]) -> List[str]:
|
|
287
|
+
if node.kind == "ref":
|
|
288
|
+
refs.append(node.path)
|
|
289
|
+
elif node.kind == "call":
|
|
290
|
+
for arg in node.args:
|
|
291
|
+
refs = _extract_refs(arg, refs)
|
|
292
|
+
return refs
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
# Evaluator
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
ResolveRef = Callable[[str], Tuple[Any, bool]]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def evaluate_derived_formula(
|
|
303
|
+
key: str,
|
|
304
|
+
formula: ParsedFormula,
|
|
305
|
+
resolve_ref: ResolveRef,
|
|
306
|
+
) -> Any:
|
|
307
|
+
value, found, err = _evaluate_node(formula.ast, resolve_ref)
|
|
308
|
+
if err:
|
|
309
|
+
raise CnosError(err)
|
|
310
|
+
if formula.ast is not None and formula.ast.kind == "ref" and not found:
|
|
311
|
+
raise CnosError(
|
|
312
|
+
f"cnos: unable to resolve derived config key {key} because {formula.ast.path} is missing"
|
|
313
|
+
)
|
|
314
|
+
return value
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _evaluate_node(
|
|
318
|
+
node: ExprNode,
|
|
319
|
+
resolve_ref: ResolveRef,
|
|
320
|
+
) -> Tuple[Any, bool, Optional[str]]:
|
|
321
|
+
if node.kind == "literal":
|
|
322
|
+
return node.value, True, None
|
|
323
|
+
if node.kind == "ref":
|
|
324
|
+
val, found = resolve_ref(node.path)
|
|
325
|
+
return val, found, None
|
|
326
|
+
if node.kind == "call":
|
|
327
|
+
values: List[Any] = []
|
|
328
|
+
flags: List[bool] = []
|
|
329
|
+
for arg in node.args:
|
|
330
|
+
val, found, err = _evaluate_node(arg, resolve_ref)
|
|
331
|
+
if err:
|
|
332
|
+
return None, False, err
|
|
333
|
+
values.append(val)
|
|
334
|
+
flags.append(found)
|
|
335
|
+
val, found, err = _evaluate_call(node.name, values, flags)
|
|
336
|
+
return val, found, err
|
|
337
|
+
return None, False, f"cnos: unsupported derive AST node {node.kind!r}"
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _evaluate_call(
|
|
341
|
+
name: str, values: List[Any], flags: List[bool]
|
|
342
|
+
) -> Tuple[Any, bool, Optional[str]]:
|
|
343
|
+
if name == "concat":
|
|
344
|
+
parts = [js_stringify_value(v) for v in values]
|
|
345
|
+
return "".join(parts), True, None
|
|
346
|
+
if name == "coalesce":
|
|
347
|
+
for v in values:
|
|
348
|
+
if v is not None:
|
|
349
|
+
return v, True, None
|
|
350
|
+
return None, True, None
|
|
351
|
+
if name == "when":
|
|
352
|
+
when_true = values[1] if len(values) > 1 else None
|
|
353
|
+
when_false = values[2] if len(values) > 2 else None
|
|
354
|
+
if _is_truthy(values[0] if values else None):
|
|
355
|
+
return when_true, True, None
|
|
356
|
+
return when_false, True, None
|
|
357
|
+
if name == "exists":
|
|
358
|
+
if not values:
|
|
359
|
+
return False, True, None
|
|
360
|
+
return (flags[0] and values[0] is not None), True, None
|
|
361
|
+
if name == "eq":
|
|
362
|
+
left = values[0] if values else None
|
|
363
|
+
right = values[1] if len(values) > 1 else None
|
|
364
|
+
return js_strict_equal(left, right), True, None
|
|
365
|
+
if name == "ne":
|
|
366
|
+
left = values[0] if values else None
|
|
367
|
+
right = values[1] if len(values) > 1 else None
|
|
368
|
+
return not js_strict_equal(left, right), True, None
|
|
369
|
+
return None, False, f"cnos: unknown derive function: {name}"
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _is_truthy(value: Any) -> bool:
|
|
373
|
+
if value is None:
|
|
374
|
+
return False
|
|
375
|
+
if isinstance(value, bool):
|
|
376
|
+
return value
|
|
377
|
+
if isinstance(value, str):
|
|
378
|
+
return bool(value)
|
|
379
|
+
if isinstance(value, (int, float)):
|
|
380
|
+
return value != 0
|
|
381
|
+
return True
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _unique_sorted(values: List[str]) -> List[str]:
|
|
385
|
+
seen: Set[str] = set()
|
|
386
|
+
result: List[str] = []
|
|
387
|
+
for v in values:
|
|
388
|
+
if v and v not in seen:
|
|
389
|
+
seen.add(v)
|
|
390
|
+
result.append(v)
|
|
391
|
+
return sorted(result)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def formula_type(formula: Optional[ParsedFormula]) -> str:
|
|
395
|
+
if formula is None:
|
|
396
|
+
return ""
|
|
397
|
+
if formula.raw and "${" in formula.raw:
|
|
398
|
+
return "template"
|
|
399
|
+
return "expression"
|