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 ADDED
@@ -0,0 +1,3 @@
1
+ """TestQL — Interface Query Language for GUI/API/encoder testing."""
2
+
3
+ __version__ = "0.1.1"
@@ -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