scythe-ttp 0.13.0__py3-none-any.whl → 0.14.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.
Potentially problematic release.
This version of scythe-ttp might be problematic. Click here for more details.
- scythe/cli/__init__.py +3 -0
- scythe/cli/main.py +601 -0
- scythe/core/headers.py +69 -9
- scythe/journeys/actions.py +172 -61
- scythe/journeys/base.py +121 -5
- scythe/journeys/executor.py +40 -1
- {scythe_ttp-0.13.0.dist-info → scythe_ttp-0.14.0.dist-info}/METADATA +83 -17
- {scythe_ttp-0.13.0.dist-info → scythe_ttp-0.14.0.dist-info}/RECORD +12 -9
- scythe_ttp-0.14.0.dist-info/entry_points.txt +2 -0
- {scythe_ttp-0.13.0.dist-info → scythe_ttp-0.14.0.dist-info}/WHEEL +0 -0
- {scythe_ttp-0.13.0.dist-info → scythe_ttp-0.14.0.dist-info}/licenses/LICENSE +0 -0
- {scythe_ttp-0.13.0.dist-info → scythe_ttp-0.14.0.dist-info}/top_level.txt +0 -0
scythe/cli/__init__.py
ADDED
scythe/cli/main.py
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import ast
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sqlite3
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
# Typer is used for the CLI. Import at module level for static analyzers; runtime guard in main.
|
|
13
|
+
try:
|
|
14
|
+
import typer # type: ignore
|
|
15
|
+
except Exception: # pragma: no cover
|
|
16
|
+
typer = None # type: ignore
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
PROJECT_DIRNAME = ".scythe"
|
|
20
|
+
DB_FILENAME = "scythe.db"
|
|
21
|
+
TESTS_DIRNAME = "scythe_tests"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
TEST_TEMPLATE = """#!/usr/bin/env python3
|
|
25
|
+
|
|
26
|
+
# scythe test initial template
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
from typing import List, Tuple
|
|
33
|
+
|
|
34
|
+
# Scythe framework imports
|
|
35
|
+
from scythe.core.executor import TTPExecutor
|
|
36
|
+
from scythe.behaviors import HumanBehavior
|
|
37
|
+
|
|
38
|
+
COMPATIBLE_VERSIONS = ["1.2.3"]
|
|
39
|
+
|
|
40
|
+
def check_url_available(url) -> bool | None:
|
|
41
|
+
import requests
|
|
42
|
+
if not url:
|
|
43
|
+
return False
|
|
44
|
+
if not (url.startswith("http://") or url.startswith("https://")):
|
|
45
|
+
url = "http://" + url
|
|
46
|
+
try:
|
|
47
|
+
r = requests.get(url, timeout=5)
|
|
48
|
+
return r.status_code < 400
|
|
49
|
+
except requests.exceptions.RequestException:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
def check_version_in_response_header(args) -> bool:
|
|
53
|
+
import requests
|
|
54
|
+
url = args.url
|
|
55
|
+
if url and not (url.startswith("http://") or url.startswith("https://")):
|
|
56
|
+
url = "http://" + url
|
|
57
|
+
r = requests.get(url)
|
|
58
|
+
h = r.headers
|
|
59
|
+
|
|
60
|
+
version = h.get('x-scythe-target-version')
|
|
61
|
+
|
|
62
|
+
if not version or version not in COMPATIBLE_VERSIONS:
|
|
63
|
+
print("This test is not compatible with the version of Scythe you are trying to run.")
|
|
64
|
+
print("Please update Scythe and try again.")
|
|
65
|
+
return False
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
def scythe_test_definition(args) -> bool:
|
|
69
|
+
# TODO: implement your test using Scythe primitives.
|
|
70
|
+
# Example placeholder that simply passes.
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main():
|
|
75
|
+
parser = argparse.ArgumentParser(description="Scythe test script")
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
'--url',
|
|
78
|
+
help='Target URL')
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
'--gate-versions',
|
|
81
|
+
default=False,
|
|
82
|
+
action='store_true',
|
|
83
|
+
dest='gate_versions',
|
|
84
|
+
help='Gate versions to test against')
|
|
85
|
+
|
|
86
|
+
args = parser.parse_args()
|
|
87
|
+
|
|
88
|
+
if check_url_available(args.url):
|
|
89
|
+
if args.gate_versions:
|
|
90
|
+
if check_version_in_response_header(args):
|
|
91
|
+
ok = scythe_test_definition(args)
|
|
92
|
+
sys.exit(0 if ok else 1)
|
|
93
|
+
else:
|
|
94
|
+
print("No compatible version found in response header.")
|
|
95
|
+
sys.exit(1)
|
|
96
|
+
else:
|
|
97
|
+
ok = scythe_test_definition(args)
|
|
98
|
+
sys.exit(0 if ok else 1)
|
|
99
|
+
else:
|
|
100
|
+
print("URL not available.")
|
|
101
|
+
sys.exit(1)
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
main()
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ScytheCLIError(Exception):
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _find_project_root(start: Optional[str] = None) -> Optional[str]:
|
|
113
|
+
"""Walk upwards from start (or cwd) to find a directory containing .scythe."""
|
|
114
|
+
cur = os.path.abspath(start or os.getcwd())
|
|
115
|
+
while True:
|
|
116
|
+
if os.path.isdir(os.path.join(cur, PROJECT_DIRNAME)):
|
|
117
|
+
return cur
|
|
118
|
+
parent = os.path.dirname(cur)
|
|
119
|
+
if parent == cur:
|
|
120
|
+
return None
|
|
121
|
+
cur = parent
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _db_path(project_root: str) -> str:
|
|
125
|
+
return os.path.join(project_root, PROJECT_DIRNAME, DB_FILENAME)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _ensure_db(conn: sqlite3.Connection) -> None:
|
|
129
|
+
cur = conn.cursor()
|
|
130
|
+
cur.execute(
|
|
131
|
+
"""
|
|
132
|
+
CREATE TABLE IF NOT EXISTS tests (
|
|
133
|
+
name TEXT PRIMARY KEY,
|
|
134
|
+
path TEXT NOT NULL,
|
|
135
|
+
created_date TEXT NOT NULL,
|
|
136
|
+
compatible_versions TEXT
|
|
137
|
+
)
|
|
138
|
+
"""
|
|
139
|
+
)
|
|
140
|
+
cur.execute(
|
|
141
|
+
"""
|
|
142
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
143
|
+
datetime TEXT NOT NULL,
|
|
144
|
+
name_of_test TEXT NOT NULL,
|
|
145
|
+
x_scythe_target_version TEXT,
|
|
146
|
+
result TEXT NOT NULL,
|
|
147
|
+
raw_output TEXT NOT NULL
|
|
148
|
+
)
|
|
149
|
+
"""
|
|
150
|
+
)
|
|
151
|
+
conn.commit()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _open_db(project_root: str) -> sqlite3.Connection:
|
|
155
|
+
path = _db_path(project_root)
|
|
156
|
+
conn = sqlite3.connect(path)
|
|
157
|
+
_ensure_db(conn)
|
|
158
|
+
return conn
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _init_project(path: str) -> str:
|
|
162
|
+
root = os.path.abspath(path or ".")
|
|
163
|
+
os.makedirs(root, exist_ok=True)
|
|
164
|
+
|
|
165
|
+
project_dir = os.path.join(root, PROJECT_DIRNAME)
|
|
166
|
+
tests_dir = os.path.join(project_dir, TESTS_DIRNAME)
|
|
167
|
+
|
|
168
|
+
os.makedirs(project_dir, exist_ok=True)
|
|
169
|
+
os.makedirs(tests_dir, exist_ok=True)
|
|
170
|
+
|
|
171
|
+
# Initialize the sqlite DB with required tables
|
|
172
|
+
conn = sqlite3.connect(os.path.join(project_dir, DB_FILENAME))
|
|
173
|
+
try:
|
|
174
|
+
_ensure_db(conn)
|
|
175
|
+
finally:
|
|
176
|
+
conn.close()
|
|
177
|
+
|
|
178
|
+
# Write a helpful README
|
|
179
|
+
readme_path = os.path.join(project_dir, "README.md")
|
|
180
|
+
if not os.path.exists(readme_path):
|
|
181
|
+
with open(readme_path, "w", encoding="utf-8") as f:
|
|
182
|
+
f.write(
|
|
183
|
+
"Scythe project directory.\n\n"
|
|
184
|
+
"- scythe.db: SQLite database for tests and runs logs.\n"
|
|
185
|
+
f"- {TESTS_DIRNAME}: Create your test scripts here.\n"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Gitignore the DB by default
|
|
189
|
+
gitignore_path = os.path.join(project_dir, ".gitignore")
|
|
190
|
+
if not os.path.exists(gitignore_path):
|
|
191
|
+
with open(gitignore_path, "w", encoding="utf-8") as f:
|
|
192
|
+
f.write("scythe.db\n")
|
|
193
|
+
|
|
194
|
+
return root
|
|
195
|
+
|
|
196
|
+
def _create_test(project_root: str, name: str) -> str:
|
|
197
|
+
if not name:
|
|
198
|
+
raise ScytheCLIError("Test name is required")
|
|
199
|
+
filename = name if name.endswith(".py") else f"{name}.py"
|
|
200
|
+
tests_dir = os.path.join(project_root, PROJECT_DIRNAME, TESTS_DIRNAME)
|
|
201
|
+
os.makedirs(tests_dir, exist_ok=True)
|
|
202
|
+
filepath = os.path.join(tests_dir, filename)
|
|
203
|
+
if os.path.exists(filepath):
|
|
204
|
+
raise ScytheCLIError(f"Test already exists: {filepath}")
|
|
205
|
+
|
|
206
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
207
|
+
f.write(TEST_TEMPLATE)
|
|
208
|
+
os.chmod(filepath, 0o755)
|
|
209
|
+
|
|
210
|
+
# Insert into DB
|
|
211
|
+
conn = _open_db(project_root)
|
|
212
|
+
try:
|
|
213
|
+
cur = conn.cursor()
|
|
214
|
+
cur.execute(
|
|
215
|
+
"INSERT OR REPLACE INTO tests(name, path, created_date, compatible_versions) VALUES(?,?,?,?)",
|
|
216
|
+
(
|
|
217
|
+
filename,
|
|
218
|
+
os.path.relpath(filepath, project_root),
|
|
219
|
+
datetime.utcnow().isoformat(timespec="seconds") + "Z",
|
|
220
|
+
"",
|
|
221
|
+
),
|
|
222
|
+
)
|
|
223
|
+
conn.commit()
|
|
224
|
+
finally:
|
|
225
|
+
conn.close()
|
|
226
|
+
|
|
227
|
+
return filepath
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
_VERSION_RE = re.compile(r"X-SCYTHE-TARGET-VERSION\s*[:=]\s*([\w\.-]+)")
|
|
231
|
+
_DETECTED_LIST_RE = re.compile(r"Detected target versions: \[?([^\]]*)\]?")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _parse_version_from_output(output: str) -> Optional[str]:
|
|
235
|
+
m = _VERSION_RE.search(output)
|
|
236
|
+
if m:
|
|
237
|
+
return m.group(1)
|
|
238
|
+
# Try from Detected target versions: ["1.2.3"] or like str(list)
|
|
239
|
+
m = _DETECTED_LIST_RE.search(output)
|
|
240
|
+
if m:
|
|
241
|
+
inner = m.group(1)
|
|
242
|
+
# extract first version-like token
|
|
243
|
+
mv = re.search(r"[\d]+(?:\.[\w\-]+)+", inner)
|
|
244
|
+
if mv:
|
|
245
|
+
return mv.group(0)
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _run_test(project_root: str, name: str, extra_args: Optional[List[str]] = None) -> Tuple[int, str, Optional[str]]:
|
|
250
|
+
filename = name if name.endswith(".py") else f"{name}.py"
|
|
251
|
+
test_path = os.path.join(project_root, PROJECT_DIRNAME, TESTS_DIRNAME, filename)
|
|
252
|
+
if not os.path.exists(test_path):
|
|
253
|
+
raise ScytheCLIError(f"Test not found: {test_path}")
|
|
254
|
+
|
|
255
|
+
# Ensure the subprocess can import the in-repo scythe package when running from a temp project
|
|
256
|
+
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
|
257
|
+
env = os.environ.copy()
|
|
258
|
+
existing_pp = env.get('PYTHONPATH', '')
|
|
259
|
+
if repo_root not in existing_pp.split(os.pathsep):
|
|
260
|
+
env['PYTHONPATH'] = os.pathsep.join([p for p in [existing_pp, repo_root] if p])
|
|
261
|
+
|
|
262
|
+
# Normalize extra args (strip a leading "--" if provided as a separator)
|
|
263
|
+
cmd_args: List[str] = []
|
|
264
|
+
if extra_args:
|
|
265
|
+
cmd_args = list(extra_args)
|
|
266
|
+
if len(cmd_args) > 0 and cmd_args[0] == "--":
|
|
267
|
+
cmd_args = cmd_args[1:]
|
|
268
|
+
|
|
269
|
+
# Execute the test as a subprocess using the same interpreter
|
|
270
|
+
proc = subprocess.run(
|
|
271
|
+
[sys.executable, test_path, *cmd_args],
|
|
272
|
+
stdout=subprocess.PIPE,
|
|
273
|
+
stderr=subprocess.STDOUT,
|
|
274
|
+
text=True,
|
|
275
|
+
cwd=project_root,
|
|
276
|
+
env=env,
|
|
277
|
+
)
|
|
278
|
+
output = proc.stdout
|
|
279
|
+
version = _parse_version_from_output(output)
|
|
280
|
+
return proc.returncode, output, version
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _record_run(project_root: str, name: str, code: int, output: str, version: Optional[str]) -> None:
|
|
284
|
+
conn = _open_db(project_root)
|
|
285
|
+
try:
|
|
286
|
+
cur = conn.cursor()
|
|
287
|
+
cur.execute(
|
|
288
|
+
"INSERT INTO runs(datetime, name_of_test, x_scythe_target_version, result, raw_output) VALUES(?,?,?,?,?)",
|
|
289
|
+
(
|
|
290
|
+
datetime.utcnow().isoformat(timespec="seconds") + "Z",
|
|
291
|
+
name if name.endswith(".py") else f"{name}.py",
|
|
292
|
+
version or "",
|
|
293
|
+
"SUCCESS" if code == 0 else "FAILURE",
|
|
294
|
+
output,
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
conn.commit()
|
|
298
|
+
finally:
|
|
299
|
+
conn.close()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _dump_db(project_root: str) -> Dict[str, List[Dict[str, str]]]:
|
|
303
|
+
conn = _open_db(project_root)
|
|
304
|
+
try:
|
|
305
|
+
cur = conn.cursor()
|
|
306
|
+
result: Dict[str, List[Dict[str, str]]] = {}
|
|
307
|
+
for table in ("tests", "runs"):
|
|
308
|
+
cur.execute(f"SELECT * FROM {table}")
|
|
309
|
+
cols = [d[0] for d in cur.description]
|
|
310
|
+
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
|
|
311
|
+
result[table] = rows
|
|
312
|
+
return result
|
|
313
|
+
finally:
|
|
314
|
+
conn.close()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _test_file_path(project_root: str, name: str) -> str:
|
|
318
|
+
filename = name if name.endswith(".py") else f"{name}.py"
|
|
319
|
+
return os.path.join(project_root, PROJECT_DIRNAME, TESTS_DIRNAME, filename)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _read_compatible_versions_from_test(test_path: str) -> Optional[List[str]]:
|
|
323
|
+
if not os.path.exists(test_path):
|
|
324
|
+
return None
|
|
325
|
+
try:
|
|
326
|
+
with open(test_path, "r", encoding="utf-8") as f:
|
|
327
|
+
src = f.read()
|
|
328
|
+
tree = ast.parse(src, filename=test_path)
|
|
329
|
+
except Exception:
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
versions: Optional[List[str]] = None
|
|
333
|
+
for node in ast.walk(tree):
|
|
334
|
+
if isinstance(node, ast.Assign):
|
|
335
|
+
# handle simple assignment COMPATIBLE_VERSIONS = [...]
|
|
336
|
+
for target in node.targets:
|
|
337
|
+
if isinstance(target, ast.Name) and target.id == "COMPATIBLE_VERSIONS":
|
|
338
|
+
val = node.value
|
|
339
|
+
if isinstance(val, (ast.List, ast.Tuple)):
|
|
340
|
+
items: List[str] = []
|
|
341
|
+
for elt in val.elts:
|
|
342
|
+
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
|
343
|
+
items.append(elt.value)
|
|
344
|
+
elif isinstance(elt, ast.Str): # py<3.8 compatibility style
|
|
345
|
+
items.append(elt.s)
|
|
346
|
+
else:
|
|
347
|
+
# unsupported element type; abort parse gracefully
|
|
348
|
+
return None
|
|
349
|
+
versions = items
|
|
350
|
+
elif isinstance(val, ast.Constant) and val.value is None:
|
|
351
|
+
versions = []
|
|
352
|
+
else:
|
|
353
|
+
return None
|
|
354
|
+
elif isinstance(node, ast.AnnAssign):
|
|
355
|
+
if isinstance(node.target, ast.Name) and node.target.id == "COMPATIBLE_VERSIONS" and node.value is not None:
|
|
356
|
+
val = node.value
|
|
357
|
+
if isinstance(val, (ast.List, ast.Tuple)):
|
|
358
|
+
items: List[str] = []
|
|
359
|
+
for elt in val.elts:
|
|
360
|
+
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
|
361
|
+
items.append(elt.value)
|
|
362
|
+
elif isinstance(elt, ast.Str):
|
|
363
|
+
items.append(elt.s)
|
|
364
|
+
else:
|
|
365
|
+
return None
|
|
366
|
+
versions = items
|
|
367
|
+
elif isinstance(val, ast.Constant) and val.value is None:
|
|
368
|
+
versions = []
|
|
369
|
+
else:
|
|
370
|
+
return None
|
|
371
|
+
return versions
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _update_test_compatible_versions(project_root: str, name: str, versions: Optional[List[str]]) -> None:
|
|
375
|
+
filename = name if name.endswith(".py") else f"{name}.py"
|
|
376
|
+
test_path_rel = os.path.relpath(_test_file_path(project_root, filename), project_root)
|
|
377
|
+
conn = _open_db(project_root)
|
|
378
|
+
try:
|
|
379
|
+
cur = conn.cursor()
|
|
380
|
+
compat_str = json.dumps(versions) if versions is not None else ""
|
|
381
|
+
cur.execute(
|
|
382
|
+
"UPDATE tests SET compatible_versions=? WHERE name=?",
|
|
383
|
+
(compat_str, filename),
|
|
384
|
+
)
|
|
385
|
+
if cur.rowcount == 0:
|
|
386
|
+
# Insert a row if it doesn't exist yet
|
|
387
|
+
cur.execute(
|
|
388
|
+
"INSERT OR REPLACE INTO tests(name, path, created_date, compatible_versions) VALUES(?,?,?,?)",
|
|
389
|
+
(
|
|
390
|
+
filename,
|
|
391
|
+
test_path_rel,
|
|
392
|
+
datetime.utcnow().isoformat(timespec="seconds") + "Z",
|
|
393
|
+
compat_str,
|
|
394
|
+
),
|
|
395
|
+
)
|
|
396
|
+
conn.commit()
|
|
397
|
+
finally:
|
|
398
|
+
conn.close()
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _sync_compat(project_root: str, name: str) -> Optional[List[str]]:
|
|
402
|
+
test_path = _test_file_path(project_root, name)
|
|
403
|
+
if not os.path.exists(test_path):
|
|
404
|
+
raise ScytheCLIError(f"Test not found: {test_path}")
|
|
405
|
+
versions = _read_compatible_versions_from_test(test_path)
|
|
406
|
+
_update_test_compatible_versions(project_root, name, versions)
|
|
407
|
+
return versions
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
411
|
+
parser = argparse.ArgumentParser(prog="scythe", description="Scythe CLI")
|
|
412
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
413
|
+
|
|
414
|
+
p_init = sub.add_parser("init", help="Initialize a new .scythe project")
|
|
415
|
+
p_init.add_argument("--path", default=".", help="Target directory (default: .)")
|
|
416
|
+
|
|
417
|
+
p_new = sub.add_parser("new", help="Create a new test in scythe_tests")
|
|
418
|
+
p_new.add_argument("name", help="Name of the test (e.g., login_smoke or login_smoke.py)")
|
|
419
|
+
|
|
420
|
+
p_run = sub.add_parser("run", help="Run a test from scythe_tests and record the run")
|
|
421
|
+
p_run.add_argument("name", help="Name of the test to run (e.g., login_smoke or login_smoke.py)")
|
|
422
|
+
p_run.add_argument(
|
|
423
|
+
"test_args",
|
|
424
|
+
nargs=argparse.REMAINDER,
|
|
425
|
+
help="Arguments to pass to the test script (use -- to separate)",
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
p_db = sub.add_parser("db", help="Database utilities")
|
|
429
|
+
sub_db = p_db.add_subparsers(dest="db_cmd", required=True)
|
|
430
|
+
sub_db.add_parser("dump", help="Dump tests and runs tables as JSON")
|
|
431
|
+
p_sync = sub_db.add_parser("sync-compat", help="Sync COMPATIBLE_VERSIONS from a test file into the DB")
|
|
432
|
+
p_sync.add_argument("name", help="Name of the test (e.g., login_smoke or login_smoke.py)")
|
|
433
|
+
|
|
434
|
+
return parser
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _legacy_main(argv: Optional[List[str]] = None) -> int:
|
|
438
|
+
"""Argparse-based fallback for environments without Typer installed."""
|
|
439
|
+
parser = build_parser()
|
|
440
|
+
args = parser.parse_args(argv)
|
|
441
|
+
|
|
442
|
+
try:
|
|
443
|
+
if args.command == "init":
|
|
444
|
+
root = _init_project(args.path)
|
|
445
|
+
print(f"Initialized Scythe project at: {root}")
|
|
446
|
+
return 0
|
|
447
|
+
|
|
448
|
+
if args.command == "new":
|
|
449
|
+
project_root = _find_project_root()
|
|
450
|
+
if not project_root:
|
|
451
|
+
raise ScytheCLIError("Not inside a Scythe project. Run 'scythe init' first.")
|
|
452
|
+
path = _create_test(project_root, args.name)
|
|
453
|
+
print(f"Created test: {path}")
|
|
454
|
+
return 0
|
|
455
|
+
|
|
456
|
+
if args.command == "run":
|
|
457
|
+
project_root = _find_project_root()
|
|
458
|
+
if not project_root:
|
|
459
|
+
raise ScytheCLIError("Not inside a Scythe project. Run 'scythe init' first.")
|
|
460
|
+
extra = getattr(args, "test_args", []) or []
|
|
461
|
+
if extra and len(extra) > 0 and extra[0] == "--":
|
|
462
|
+
extra = extra[1:]
|
|
463
|
+
code, output, version = _run_test(project_root, args.name, extra)
|
|
464
|
+
_record_run(project_root, args.name, code, output, version)
|
|
465
|
+
print(output)
|
|
466
|
+
return code
|
|
467
|
+
|
|
468
|
+
if args.command == "db":
|
|
469
|
+
project_root = _find_project_root()
|
|
470
|
+
if not project_root:
|
|
471
|
+
raise ScytheCLIError("Not inside a Scythe project. Run 'scythe init' first.")
|
|
472
|
+
if args.db_cmd == "dump":
|
|
473
|
+
data = _dump_db(project_root)
|
|
474
|
+
print(json.dumps(data, indent=2))
|
|
475
|
+
return 0
|
|
476
|
+
if args.db_cmd == "sync-compat":
|
|
477
|
+
versions = _sync_compat(project_root, args.name)
|
|
478
|
+
filename = args.name if args.name.endswith(".py") else f"{args.name}.py"
|
|
479
|
+
if versions is None:
|
|
480
|
+
print(f"No COMPATIBLE_VERSIONS found in {filename}; DB updated with empty value.")
|
|
481
|
+
else:
|
|
482
|
+
print(f"Updated {filename} compatible_versions to: {json.dumps(versions)}")
|
|
483
|
+
return 0
|
|
484
|
+
|
|
485
|
+
raise ScytheCLIError("Unknown command")
|
|
486
|
+
except ScytheCLIError as e:
|
|
487
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
488
|
+
return 2
|
|
489
|
+
except FileNotFoundError as e:
|
|
490
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
491
|
+
return 2
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
495
|
+
"""Typer-based CLI entry point. When called programmatically, returns an exit code int.
|
|
496
|
+
|
|
497
|
+
This constructs a Typer app with subcommands equivalent to the previous argparse version,
|
|
498
|
+
then dispatches using Click's command runner with standalone_mode=False to capture return codes.
|
|
499
|
+
"""
|
|
500
|
+
try:
|
|
501
|
+
import typer
|
|
502
|
+
except Exception:
|
|
503
|
+
# Fallback to legacy argparse-based implementation if Typer is not available
|
|
504
|
+
return _legacy_main(argv)
|
|
505
|
+
|
|
506
|
+
app = typer.Typer(add_completion=False, help="Scythe CLI")
|
|
507
|
+
|
|
508
|
+
@app.command()
|
|
509
|
+
def init(
|
|
510
|
+
path: str = typer.Option(
|
|
511
|
+
".",
|
|
512
|
+
"--path",
|
|
513
|
+
"-p",
|
|
514
|
+
help="Target directory (default: .)",
|
|
515
|
+
)
|
|
516
|
+
) -> int:
|
|
517
|
+
"""Initialize a new .scythe project"""
|
|
518
|
+
root = _init_project(path)
|
|
519
|
+
print(f"Initialized Scythe project at: {root}")
|
|
520
|
+
return 0
|
|
521
|
+
|
|
522
|
+
@app.command()
|
|
523
|
+
def new(
|
|
524
|
+
name: str = typer.Argument(..., help="Name of the test (e.g., login_smoke or login_smoke.py)")
|
|
525
|
+
) -> int:
|
|
526
|
+
"""Create a new test in scythe_tests"""
|
|
527
|
+
project_root = _find_project_root()
|
|
528
|
+
if not project_root:
|
|
529
|
+
raise ScytheCLIError("Not inside a Scythe project. Run 'scythe init' first.")
|
|
530
|
+
path = _create_test(project_root, name)
|
|
531
|
+
print(f"Created test: {path}")
|
|
532
|
+
return 0
|
|
533
|
+
|
|
534
|
+
@app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
|
|
535
|
+
def run(
|
|
536
|
+
ctx: typer.Context,
|
|
537
|
+
name: str = typer.Argument(..., help="Name of the test to run (e.g., login_smoke or login_smoke.py)"),
|
|
538
|
+
test_args: List[str] = typer.Argument(
|
|
539
|
+
None,
|
|
540
|
+
help="Arguments to pass to the test script (you can pass options directly or use -- to separate)",
|
|
541
|
+
metavar="[-- ARGS...]",
|
|
542
|
+
),
|
|
543
|
+
) -> int:
|
|
544
|
+
"""Run a test from scythe_tests and record the run"""
|
|
545
|
+
project_root = _find_project_root()
|
|
546
|
+
if not project_root:
|
|
547
|
+
raise ScytheCLIError("Not inside a Scythe project. Run 'scythe init' first.")
|
|
548
|
+
extra: List[str] = []
|
|
549
|
+
if test_args:
|
|
550
|
+
extra.extend(list(test_args))
|
|
551
|
+
if getattr(ctx, "args", None):
|
|
552
|
+
extra.extend(list(ctx.args))
|
|
553
|
+
if extra and len(extra) > 0 and extra[0] == "--":
|
|
554
|
+
extra = extra[1:]
|
|
555
|
+
code, output, version = _run_test(project_root, name, extra)
|
|
556
|
+
_record_run(project_root, name, code, output, version)
|
|
557
|
+
print(output)
|
|
558
|
+
return code
|
|
559
|
+
|
|
560
|
+
db_app = typer.Typer(help="Database utilities")
|
|
561
|
+
|
|
562
|
+
@db_app.command("dump")
|
|
563
|
+
def dump() -> int:
|
|
564
|
+
"""Dump tests and runs tables as JSON"""
|
|
565
|
+
project_root = _find_project_root()
|
|
566
|
+
if not project_root:
|
|
567
|
+
raise ScytheCLIError("Not inside a Scythe project. Run 'scythe init' first.")
|
|
568
|
+
data = _dump_db(project_root)
|
|
569
|
+
print(json.dumps(data, indent=2))
|
|
570
|
+
return 0
|
|
571
|
+
|
|
572
|
+
@db_app.command("sync-compat")
|
|
573
|
+
def sync_compat(
|
|
574
|
+
name: str = typer.Argument(..., help="Name of the test (e.g., login_smoke or login_smoke.py)")
|
|
575
|
+
) -> int:
|
|
576
|
+
"""Sync COMPATIBLE_VERSIONS from a test file into the DB"""
|
|
577
|
+
project_root = _find_project_root()
|
|
578
|
+
if not project_root:
|
|
579
|
+
raise ScytheCLIError("Not inside a Scythe project. Run 'scythe init' first.")
|
|
580
|
+
versions = _sync_compat(project_root, name)
|
|
581
|
+
filename = name if name.endswith(".py") else f"{name}.py"
|
|
582
|
+
if versions is None:
|
|
583
|
+
print(f"No COMPATIBLE_VERSIONS found in {filename}; DB updated with empty value.")
|
|
584
|
+
else:
|
|
585
|
+
print(f"Updated {filename} compatible_versions to: {json.dumps(versions)}")
|
|
586
|
+
return 0
|
|
587
|
+
|
|
588
|
+
app.add_typer(db_app, name="db")
|
|
589
|
+
|
|
590
|
+
if argv is None:
|
|
591
|
+
argv = sys.argv[1:]
|
|
592
|
+
|
|
593
|
+
try:
|
|
594
|
+
rv = app(prog_name="scythe", args=argv, standalone_mode=False)
|
|
595
|
+
return int(rv) if isinstance(rv, int) else 0
|
|
596
|
+
except ScytheCLIError as e:
|
|
597
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
598
|
+
return 2
|
|
599
|
+
except SystemExit as e:
|
|
600
|
+
# should not occur with standalone_mode=False, but handle defensively
|
|
601
|
+
return int(getattr(e, "code", 0) or 0)
|