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 ADDED
@@ -0,0 +1,3 @@
1
+ from .main import main
2
+
3
+ __all__ = ["main"]
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)