testql 0.1.1__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.
- testql/__init__.py +3 -0
- testql/_base_fallback.py +220 -0
- testql/base.py +37 -0
- testql/cli.py +60 -0
- testql/commands/__init__.py +0 -0
- testql/commands/encoder_routes.py +403 -0
- testql/interpreter.py +543 -0
- testql/reporters/__init__.py +0 -0
- testql/reporters/console.py +38 -0
- testql/reporters/json_reporter.py +33 -0
- testql/runner.py +371 -0
- testql/runners/__init__.py +0 -0
- testql-0.1.1.dist-info/METADATA +273 -0
- testql-0.1.1.dist-info/RECORD +18 -0
- testql-0.1.1.dist-info/WHEEL +5 -0
- testql-0.1.1.dist-info/entry_points.txt +2 -0
- testql-0.1.1.dist-info/licenses/LICENSE +201 -0
- testql-0.1.1.dist-info/top_level.txt +1 -0
testql/__init__.py
ADDED
testql/_base_fallback.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dsl/interpreter/base.py — Shared base classes for CQL and IQL interpreters.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
# ── Result types ─────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
class StepStatus(Enum):
|
|
17
|
+
PENDING = "pending"
|
|
18
|
+
RUNNING = "running"
|
|
19
|
+
PASSED = "passed"
|
|
20
|
+
FAILED = "failed"
|
|
21
|
+
SKIPPED = "skipped"
|
|
22
|
+
ERROR = "error"
|
|
23
|
+
WARNING = "warning"
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class StepResult:
|
|
27
|
+
name: str
|
|
28
|
+
status: StepStatus
|
|
29
|
+
value: Any = None
|
|
30
|
+
message: str = ""
|
|
31
|
+
duration_ms: float = 0.0
|
|
32
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ScriptResult:
|
|
36
|
+
source: str
|
|
37
|
+
ok: bool
|
|
38
|
+
steps: list[StepResult] = field(default_factory=list)
|
|
39
|
+
variables: dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
errors: list[str] = field(default_factory=list)
|
|
41
|
+
warnings: list[str] = field(default_factory=list)
|
|
42
|
+
duration_ms: float = 0.0
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def passed(self) -> int:
|
|
46
|
+
return sum(1 for s in self.steps if s.status == StepStatus.PASSED)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def failed(self) -> int:
|
|
50
|
+
return sum(1 for s in self.steps if s.status in (StepStatus.FAILED, StepStatus.ERROR))
|
|
51
|
+
|
|
52
|
+
def summary(self) -> str:
|
|
53
|
+
total = len(self.steps)
|
|
54
|
+
icon = "✅" if self.ok else "❌"
|
|
55
|
+
return f"{icon} {self.source}: {self.passed}/{total} passed, {self.failed} failed ({self.duration_ms:.0f}ms)"
|
|
56
|
+
|
|
57
|
+
# ── Variable store ───────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
class VariableStore:
|
|
60
|
+
"""Simple key-value store with interpolation support."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, initial: dict[str, Any] | None = None):
|
|
63
|
+
self._vars: dict[str, Any] = dict(initial or {})
|
|
64
|
+
|
|
65
|
+
def set(self, key: str, value: Any) -> None:
|
|
66
|
+
self._vars[key] = value
|
|
67
|
+
|
|
68
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
69
|
+
return self._vars.get(key, default)
|
|
70
|
+
|
|
71
|
+
def has(self, key: str) -> bool:
|
|
72
|
+
return key in self._vars
|
|
73
|
+
|
|
74
|
+
def all(self) -> dict[str, Any]:
|
|
75
|
+
return dict(self._vars)
|
|
76
|
+
|
|
77
|
+
def clear(self) -> None:
|
|
78
|
+
self._vars.clear()
|
|
79
|
+
|
|
80
|
+
def interpolate(self, text: str) -> str:
|
|
81
|
+
"""Replace ${var} and $var references in text."""
|
|
82
|
+
def _repl(m: re.Match) -> str:
|
|
83
|
+
key = m.group(1) or m.group(2)
|
|
84
|
+
val = self._vars.get(key)
|
|
85
|
+
return str(val) if val is not None else m.group(0)
|
|
86
|
+
# ${var} first, then $var (word chars only)
|
|
87
|
+
text = re.sub(r'\$\{([^}]+)\}', _repl, text)
|
|
88
|
+
text = re.sub(r'\$([A-Za-z_]\w*)', _repl, text)
|
|
89
|
+
return text
|
|
90
|
+
|
|
91
|
+
# ── Output / logging ────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
class InterpreterOutput:
|
|
94
|
+
"""Collects interpreter output lines for display or testing."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, quiet: bool = False):
|
|
97
|
+
self.quiet = quiet
|
|
98
|
+
self.lines: list[str] = []
|
|
99
|
+
|
|
100
|
+
def emit(self, msg: str) -> None:
|
|
101
|
+
self.lines.append(msg)
|
|
102
|
+
if not self.quiet:
|
|
103
|
+
print(msg)
|
|
104
|
+
|
|
105
|
+
def info(self, msg: str) -> None:
|
|
106
|
+
self.emit(f"ℹ️ {msg}")
|
|
107
|
+
|
|
108
|
+
def ok(self, msg: str) -> None:
|
|
109
|
+
self.emit(f"✅ {msg}")
|
|
110
|
+
|
|
111
|
+
def fail(self, msg: str) -> None:
|
|
112
|
+
self.emit(f"❌ {msg}")
|
|
113
|
+
|
|
114
|
+
def warn(self, msg: str) -> None:
|
|
115
|
+
self.emit(f"⚠️ {msg}")
|
|
116
|
+
|
|
117
|
+
def error(self, msg: str) -> None:
|
|
118
|
+
self.emit(f"❌ {msg}")
|
|
119
|
+
|
|
120
|
+
def step(self, icon: str, msg: str) -> None:
|
|
121
|
+
self.emit(f"{icon} {msg}")
|
|
122
|
+
|
|
123
|
+
# ── Base interpreter ─────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
class BaseInterpreter(ABC):
|
|
126
|
+
"""Abstract base for language interpreters."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, variables: dict[str, Any] | None = None, quiet: bool = False):
|
|
129
|
+
self.vars = VariableStore(variables)
|
|
130
|
+
self.out = InterpreterOutput(quiet=quiet)
|
|
131
|
+
self.results: list[StepResult] = []
|
|
132
|
+
self.errors: list[str] = []
|
|
133
|
+
self.warnings: list[str] = []
|
|
134
|
+
|
|
135
|
+
@abstractmethod
|
|
136
|
+
def parse(self, source: str, filename: str = "<string>") -> Any:
|
|
137
|
+
"""Parse source into an AST / structure."""
|
|
138
|
+
...
|
|
139
|
+
|
|
140
|
+
@abstractmethod
|
|
141
|
+
def execute(self, parsed: Any) -> ScriptResult:
|
|
142
|
+
"""Execute parsed structure."""
|
|
143
|
+
...
|
|
144
|
+
|
|
145
|
+
def run(self, source: str, filename: str = "<string>") -> ScriptResult:
|
|
146
|
+
"""Parse + execute in one step."""
|
|
147
|
+
t0 = time.monotonic()
|
|
148
|
+
parsed = self.parse(source, filename)
|
|
149
|
+
result = self.execute(parsed)
|
|
150
|
+
result.duration_ms = (time.monotonic() - t0) * 1000
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
def run_file(self, path: str) -> ScriptResult:
|
|
154
|
+
"""Load file and run."""
|
|
155
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
156
|
+
source = f.read()
|
|
157
|
+
return self.run(source, filename=path)
|
|
158
|
+
|
|
159
|
+
# Helpers
|
|
160
|
+
@staticmethod
|
|
161
|
+
def strip_comments(lines: list[str]) -> list[str]:
|
|
162
|
+
"""Remove comment-only lines and inline comments."""
|
|
163
|
+
out = []
|
|
164
|
+
for line in lines:
|
|
165
|
+
stripped = line.split("#")[0].rstrip() if "#" in line else line.rstrip()
|
|
166
|
+
out.append(stripped)
|
|
167
|
+
return out
|
|
168
|
+
|
|
169
|
+
# ── WebSocket bridge (optional, for browser sync) ───────────────────────────
|
|
170
|
+
|
|
171
|
+
class EventBridge:
|
|
172
|
+
"""Optional WebSocket bridge to DSL Event Server (port 8104).
|
|
173
|
+
|
|
174
|
+
When connected, events emitted by interpreters are broadcast
|
|
175
|
+
to all connected browser clients via the event server.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def __init__(self, url: str = "ws://localhost:8104/cli"):
|
|
179
|
+
self.url = url
|
|
180
|
+
self._ws: Any = None
|
|
181
|
+
self._connected = False
|
|
182
|
+
|
|
183
|
+
async def connect(self) -> bool:
|
|
184
|
+
try:
|
|
185
|
+
import websockets
|
|
186
|
+
self._ws = await websockets.connect(self.url)
|
|
187
|
+
self._connected = True
|
|
188
|
+
return True
|
|
189
|
+
except Exception:
|
|
190
|
+
self._connected = False
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
async def disconnect(self) -> None:
|
|
194
|
+
if self._ws:
|
|
195
|
+
try:
|
|
196
|
+
await self._ws.close()
|
|
197
|
+
except Exception:
|
|
198
|
+
pass
|
|
199
|
+
self._ws = None
|
|
200
|
+
self._connected = False
|
|
201
|
+
|
|
202
|
+
async def send_event(self, event_type: str, payload: dict[str, Any]) -> None:
|
|
203
|
+
if not self._connected or not self._ws:
|
|
204
|
+
return
|
|
205
|
+
import json
|
|
206
|
+
event = {
|
|
207
|
+
"id": f"evt-{int(time.time() * 1000):x}-{id(payload) & 0xffff:04x}",
|
|
208
|
+
"type": event_type,
|
|
209
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
210
|
+
"payload": payload,
|
|
211
|
+
"metadata": {"source": "cli"},
|
|
212
|
+
}
|
|
213
|
+
try:
|
|
214
|
+
await self._ws.send(json.dumps(event))
|
|
215
|
+
except Exception:
|
|
216
|
+
self._connected = False
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def connected(self) -> bool:
|
|
220
|
+
return self._connected
|
testql/base.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
testql/base.py — Bridge to oqlos.core.base (authoritative source).
|
|
3
|
+
|
|
4
|
+
Falls back to a bundled copy if oqlos is not installed,
|
|
5
|
+
so testql stays usable as a standalone package.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from oqlos.core.base import ( # noqa: F401
|
|
10
|
+
BaseInterpreter,
|
|
11
|
+
EventBridge,
|
|
12
|
+
InterpreterOutput,
|
|
13
|
+
ScriptResult,
|
|
14
|
+
StepResult,
|
|
15
|
+
StepStatus,
|
|
16
|
+
VariableStore,
|
|
17
|
+
)
|
|
18
|
+
except ImportError: # oqlos not installed — use bundled fallback
|
|
19
|
+
from testql._base_fallback import ( # noqa: F401
|
|
20
|
+
BaseInterpreter,
|
|
21
|
+
EventBridge,
|
|
22
|
+
InterpreterOutput,
|
|
23
|
+
ScriptResult,
|
|
24
|
+
StepResult,
|
|
25
|
+
StepStatus,
|
|
26
|
+
VariableStore,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"BaseInterpreter",
|
|
31
|
+
"EventBridge",
|
|
32
|
+
"InterpreterOutput",
|
|
33
|
+
"ScriptResult",
|
|
34
|
+
"StepResult",
|
|
35
|
+
"StepStatus",
|
|
36
|
+
"VariableStore",
|
|
37
|
+
]
|
testql/cli.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""TestQL CLI — run .tql scenarios from the command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.command()
|
|
12
|
+
@click.argument("file", type=click.Path(exists=True))
|
|
13
|
+
@click.option("--url", default="http://localhost:8101", help="Base API URL")
|
|
14
|
+
@click.option("--dry-run", is_flag=True, help="Parse and validate without executing")
|
|
15
|
+
@click.option(
|
|
16
|
+
"--output",
|
|
17
|
+
type=click.Choice(["console", "json"]),
|
|
18
|
+
default="console",
|
|
19
|
+
help="Output format",
|
|
20
|
+
)
|
|
21
|
+
@click.option("--quiet", is_flag=True, help="Suppress step-by-step output")
|
|
22
|
+
def main(file: str, url: str, dry_run: bool, output: str, quiet: bool) -> None:
|
|
23
|
+
"""Run a TestQL (.tql) scenario."""
|
|
24
|
+
from testql.interpreter import IqlInterpreter
|
|
25
|
+
|
|
26
|
+
source = Path(file).read_text(encoding="utf-8")
|
|
27
|
+
filename = Path(file).name
|
|
28
|
+
|
|
29
|
+
interp = IqlInterpreter(
|
|
30
|
+
api_url=url,
|
|
31
|
+
dry_run=dry_run,
|
|
32
|
+
quiet=quiet,
|
|
33
|
+
include_paths=[str(Path(file).parent), "."],
|
|
34
|
+
)
|
|
35
|
+
result = interp.run(source, filename)
|
|
36
|
+
|
|
37
|
+
if output == "json":
|
|
38
|
+
import json
|
|
39
|
+
|
|
40
|
+
print(
|
|
41
|
+
json.dumps(
|
|
42
|
+
{
|
|
43
|
+
"source": result.source,
|
|
44
|
+
"ok": result.ok,
|
|
45
|
+
"passed": result.passed,
|
|
46
|
+
"failed": result.failed,
|
|
47
|
+
"steps": len(result.steps),
|
|
48
|
+
"duration_ms": round(result.duration_ms, 1),
|
|
49
|
+
"errors": result.errors,
|
|
50
|
+
"warnings": result.warnings,
|
|
51
|
+
},
|
|
52
|
+
indent=2,
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
sys.exit(0 if result.ok else 1)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
main()
|
|
File without changes
|