drun 2.1.2__tar.gz → 2.2.2__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.
Files changed (61) hide show
  1. {drun-2.1.2 → drun-2.2.2}/PKG-INFO +1 -1
  2. {drun-2.1.2 → drun-2.2.2}/drun/__init__.py +1 -1
  3. {drun-2.1.2 → drun-2.2.2}/drun/cli.py +11 -17
  4. {drun-2.1.2 → drun-2.2.2}/drun/reporter/html_reporter.py +21 -7
  5. {drun-2.1.2 → drun-2.2.2}/drun/runner/assertions.py +15 -0
  6. {drun-2.1.2 → drun-2.2.2}/drun/runner/runner.py +8 -2
  7. {drun-2.1.2 → drun-2.2.2}/drun/scaffolds/__init__.py +2 -0
  8. {drun-2.1.2 → drun-2.2.2}/drun/scaffolds/templates.py +249 -0
  9. {drun-2.1.2 → drun-2.2.2}/drun/utils/curl.py +20 -6
  10. {drun-2.1.2 → drun-2.2.2}/drun.egg-info/PKG-INFO +1 -1
  11. {drun-2.1.2 → drun-2.2.2}/pyproject.toml +1 -1
  12. {drun-2.1.2 → drun-2.2.2}/LICENSE +0 -0
  13. {drun-2.1.2 → drun-2.2.2}/README.md +0 -0
  14. {drun-2.1.2 → drun-2.2.2}/drun/db/__init__.py +0 -0
  15. {drun-2.1.2 → drun-2.2.2}/drun/db/database_proxy.py +0 -0
  16. {drun-2.1.2 → drun-2.2.2}/drun/db/generate_mysql_config.py +0 -0
  17. {drun-2.1.2 → drun-2.2.2}/drun/engine/__init__.py +0 -0
  18. {drun-2.1.2 → drun-2.2.2}/drun/engine/http.py +0 -0
  19. {drun-2.1.2 → drun-2.2.2}/drun/exporters/curl.py +0 -0
  20. {drun-2.1.2 → drun-2.2.2}/drun/importers/base.py +0 -0
  21. {drun-2.1.2 → drun-2.2.2}/drun/importers/curl.py +0 -0
  22. {drun-2.1.2 → drun-2.2.2}/drun/importers/har.py +0 -0
  23. {drun-2.1.2 → drun-2.2.2}/drun/importers/openapi.py +0 -0
  24. {drun-2.1.2 → drun-2.2.2}/drun/importers/postman.py +0 -0
  25. {drun-2.1.2 → drun-2.2.2}/drun/loader/__init__.py +0 -0
  26. {drun-2.1.2 → drun-2.2.2}/drun/loader/collector.py +0 -0
  27. {drun-2.1.2 → drun-2.2.2}/drun/loader/env.py +0 -0
  28. {drun-2.1.2 → drun-2.2.2}/drun/loader/hooks.py +0 -0
  29. {drun-2.1.2 → drun-2.2.2}/drun/loader/yaml_loader.py +0 -0
  30. {drun-2.1.2 → drun-2.2.2}/drun/models/case.py +0 -0
  31. {drun-2.1.2 → drun-2.2.2}/drun/models/config.py +0 -0
  32. {drun-2.1.2 → drun-2.2.2}/drun/models/report.py +0 -0
  33. {drun-2.1.2 → drun-2.2.2}/drun/models/request.py +0 -0
  34. {drun-2.1.2 → drun-2.2.2}/drun/models/step.py +0 -0
  35. {drun-2.1.2 → drun-2.2.2}/drun/models/validators.py +0 -0
  36. {drun-2.1.2 → drun-2.2.2}/drun/notifier/__init__.py +0 -0
  37. {drun-2.1.2 → drun-2.2.2}/drun/notifier/base.py +0 -0
  38. {drun-2.1.2 → drun-2.2.2}/drun/notifier/dingtalk.py +0 -0
  39. {drun-2.1.2 → drun-2.2.2}/drun/notifier/emailer.py +0 -0
  40. {drun-2.1.2 → drun-2.2.2}/drun/notifier/feishu.py +0 -0
  41. {drun-2.1.2 → drun-2.2.2}/drun/notifier/format.py +0 -0
  42. {drun-2.1.2 → drun-2.2.2}/drun/reporter/__init__.py +0 -0
  43. {drun-2.1.2 → drun-2.2.2}/drun/reporter/allure_reporter.py +0 -0
  44. {drun-2.1.2 → drun-2.2.2}/drun/reporter/json_reporter.py +0 -0
  45. {drun-2.1.2 → drun-2.2.2}/drun/runner/__init__.py +0 -0
  46. {drun-2.1.2 → drun-2.2.2}/drun/runner/extractors.py +0 -0
  47. {drun-2.1.2 → drun-2.2.2}/drun/templating/__init__.py +0 -0
  48. {drun-2.1.2 → drun-2.2.2}/drun/templating/builtins.py +0 -0
  49. {drun-2.1.2 → drun-2.2.2}/drun/templating/context.py +0 -0
  50. {drun-2.1.2 → drun-2.2.2}/drun/templating/engine.py +0 -0
  51. {drun-2.1.2 → drun-2.2.2}/drun/utils/__init__.py +0 -0
  52. {drun-2.1.2 → drun-2.2.2}/drun/utils/errors.py +0 -0
  53. {drun-2.1.2 → drun-2.2.2}/drun/utils/logging.py +0 -0
  54. {drun-2.1.2 → drun-2.2.2}/drun/utils/mask.py +0 -0
  55. {drun-2.1.2 → drun-2.2.2}/drun/utils/timeit.py +0 -0
  56. {drun-2.1.2 → drun-2.2.2}/drun.egg-info/SOURCES.txt +0 -0
  57. {drun-2.1.2 → drun-2.2.2}/drun.egg-info/dependency_links.txt +0 -0
  58. {drun-2.1.2 → drun-2.2.2}/drun.egg-info/entry_points.txt +0 -0
  59. {drun-2.1.2 → drun-2.2.2}/drun.egg-info/requires.txt +0 -0
  60. {drun-2.1.2 → drun-2.2.2}/drun.egg-info/top_level.txt +0 -0
  61. {drun-2.1.2 → drun-2.2.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drun
3
- Version: 2.1.2
3
+ Version: 2.2.2
4
4
  Summary: Minimal HTTP API test runner (MVP)
5
5
  Author: Drun Team
6
6
  Requires-Python: >=3.10
@@ -1,2 +1,2 @@
1
1
  __all__ = ["__version__"]
2
- __version__ = "2.1.2"
2
+ __version__ = "2.2.2"
@@ -1677,7 +1677,7 @@ def init_project(
1677
1677
  from drun import scaffolds
1678
1678
 
1679
1679
  # Display version
1680
- typer.echo(f"Drun v{_get_drun_version()}\n")
1680
+ typer.echo(f"Drun v{_get_drun_version()}")
1681
1681
 
1682
1682
  # 确定目标目录
1683
1683
  if name:
@@ -1752,6 +1752,9 @@ def init_project(
1752
1752
  # testcases/test_stream.yaml
1753
1753
  _write_template("testcases/test_stream.yaml", scaffolds.STREAM_TESTCASE)
1754
1754
 
1755
+ # testcases/test_assertions.yaml
1756
+ _write_template("testcases/test_assertions.yaml", scaffolds.ASSERTIONS_TESTCASE)
1757
+
1755
1758
  # data/users.csv
1756
1759
  _write_template("data/users.csv", scaffolds.CSV_USERS_SAMPLE)
1757
1760
 
@@ -1806,7 +1809,8 @@ def init_project(
1806
1809
  ("│ ├── ", "test_api_health.yaml", "健康检查示例"),
1807
1810
  ("│ ├── ", "test_stream.yaml", "流式响应 (SSE) 示例"),
1808
1811
  ("│ ├── ", "test_db_assert.yaml", "数据库断言示例"),
1809
- ("│ └── ", "test_import_users.yaml", "CSV 参数化示例"),
1812
+ ("│ ├── ", "test_import_users.yaml", "CSV 参数化示例"),
1813
+ ("│ └── ", "test_assertions.yaml", "断言操作符完整示例"),
1810
1814
  ("├── ", "testsuites/", ""),
1811
1815
  ("│ ├── ", "testsuite_smoke.yaml", "冒烟测试套件"),
1812
1816
  ("│ └── ", "testsuite_csv.yaml", "CSV 示例套件"),
@@ -1841,7 +1845,7 @@ def init_project(
1841
1845
  else:
1842
1846
  typer.echo(full)
1843
1847
  typer.echo("")
1844
- typer.echo("9 directories, 17 files")
1848
+ typer.echo("9 directories, 18 files")
1845
1849
 
1846
1850
  if skipped_files:
1847
1851
  typer.echo("")
@@ -1862,20 +1866,10 @@ def init_project(
1862
1866
  if name:
1863
1867
  typer.echo(f" cd {name}")
1864
1868
  typer.echo(" drun run testcases/test_api_health.yaml")
1865
- # httpstat 功能已删除
1866
- typer.echo(" drun run testcases/test_stream.yaml # 流式响应 (SSE) 示例")
1867
- typer.echo(" drun run testcases/test_db_assert.yaml # 数据库断言示例")
1868
- typer.echo(" drun run testcases/test_import_users.yaml # CSV 数据驱动示例")
1869
- typer.echo("")
1870
- typer.echo("格式转换 (查看 converts/README.md 获取详细说明):")
1871
- typer.echo(" - cURL 转用例:")
1872
- typer.echo(" drun convert converts/curl/sample.curl --outfile testcases/new_test.yaml")
1873
- typer.echo(" - Postman 转用例:")
1874
- typer.echo(" drun convert converts/postman/sample_collection.json --split-output --suite-out testsuites/new_suite.yaml")
1875
- typer.echo(" - HAR 转用例:")
1876
- typer.echo(" drun convert converts/har/sample_recording.har --exclude-static --only-2xx --outfile testcases/from_har.yaml")
1877
- typer.echo(" - OpenAPI 转用例:")
1878
- typer.echo(" drun convert-openapi converts/openapi/sample_openapi.json --split-output --outfile testcases/from_openapi.yaml")
1869
+ typer.echo(" drun run testcases/test_stream.yaml")
1870
+ typer.echo(" drun run testcases/test_db_assert.yaml")
1871
+ typer.echo(" drun run testcases/test_import_users.yaml")
1872
+ typer.echo(" drun run testcases/test_assertions.yaml")
1879
1873
  typer.echo("")
1880
1874
  typer.echo("文档: https://github.com/Devliang24/drun")
1881
1875
 
@@ -44,14 +44,28 @@ def _escape_html(text: str) -> str:
44
44
  )
45
45
 
46
46
 
47
+ def _format_assert_value(value: Any) -> str:
48
+ """Format assertion value: strings without quotes, others with JSON"""
49
+ if value is None:
50
+ return "null"
51
+ if isinstance(value, str):
52
+ return value
53
+ if isinstance(value, bool):
54
+ return "true" if value else "false"
55
+ if isinstance(value, (int, float)):
56
+ return str(value)
57
+ # For lists and dicts, use JSON formatting
58
+ return json.dumps(value, ensure_ascii=False)
59
+
60
+
47
61
  def _build_assert_table(asserts: List[AssertionResult]) -> str:
48
62
  rows = []
49
63
  for a in asserts or []:
50
64
  cells = [
51
65
  f"<td><code>{_escape_html(str(a.check))}</code></td>",
52
66
  f"<td><code>{_escape_html(str(a.comparator))}</code></td>",
53
- f"<td><code>{_escape_html(json.dumps(a.expect, ensure_ascii=False))}</code></td>",
54
- f"<td><code>{_escape_html(json.dumps(a.actual, ensure_ascii=False))}</code></td>",
67
+ f"<td><code>{_escape_html(_format_assert_value(a.expect))}</code></td>",
68
+ f"<td><code>{_escape_html(_format_assert_value(a.actual))}</code></td>",
55
69
  ("<td><span class='ok'>✓</span></td>" if a.passed else f"<td><span class='err' title='{_escape_html(a.message or '')}'>✗</span></td>")
56
70
  ]
57
71
  rows.append("<tr " + ("data-pass=1" if a.passed else "data-pass=0") + ">" + "".join(cells) + "</tr>")
@@ -428,14 +442,14 @@ def write_html(report: RunReport, outfile: str | Path) -> None:
428
442
  .panel .p-head { padding:6px 8px; background:var(--panel-head-bg); color:var(--muted); font-size:12px; display:flex; justify-content:space-between; align-items:center; }
429
443
  .panel .p-head .actions { display:flex; gap:6px; }
430
444
  .panel pre, .panel table { margin:0; padding:10px; overflow:auto; max-height: 360px; }
431
- .panel[data-section='curl'] pre { white-space: pre-wrap; word-break: break-word; }
445
+ .panel[data-section='curl'] pre { white-space: pre; overflow-x: auto; word-break: normal; }
432
446
  table { width: 100%; border-collapse: collapse; table-layout: fixed; }
433
447
  th { padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; text-align: left; font-weight: 600; }
434
448
  td { padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; word-break: break-word; }
435
- .assert-table th:nth-child(1), .assert-table td:nth-child(1) { width: 25%; }
436
- .assert-table th:nth-child(2), .assert-table td:nth-child(2) { width: 10%; }
437
- .assert-table th:nth-child(3), .assert-table td:nth-child(3) { width: 25%; }
438
- .assert-table th:nth-child(4), .assert-table td:nth-child(4) { width: 25%; }
449
+ .assert-table th:nth-child(1), .assert-table td:nth-child(1) { width: 23%; }
450
+ .assert-table th:nth-child(2), .assert-table td:nth-child(2) { width: 15%; }
451
+ .assert-table th:nth-child(3), .assert-table td:nth-child(3) { width: 23%; }
452
+ .assert-table th:nth-child(4), .assert-table td:nth-child(4) { width: 24%; }
439
453
  .assert-table th:nth-child(5), .assert-table td:nth-child(5) { width: 15%; text-align: center; }
440
454
  .ok { color: var(--ok); }
441
455
  .err { color: var(--fail); }
@@ -23,6 +23,19 @@ def op_ge(a: Any, b: Any) -> bool: return a >= b
23
23
  def op_len_eq(a: Any, b: Any) -> bool: return _len(a) == int(b)
24
24
  def op_in(a: Any, b: Any) -> bool: return a in b if b is not None else False
25
25
  def op_not_in(a: Any, b: Any) -> bool: return a not in b if b is not None else True
26
+ def op_contains_all(a: Any, b: Any) -> bool:
27
+ """Check if all elements in list a contain string b"""
28
+ if not isinstance(a, list):
29
+ return False
30
+ if not b:
31
+ return False
32
+ return all(str(b) in str(item) for item in a)
33
+ def op_match_regex_all(a: Any, b: Any) -> bool:
34
+ """Check if all elements in list a match regex pattern b"""
35
+ if not isinstance(a, list):
36
+ return False
37
+ pattern = str(b)
38
+ return all(bool(re.search(pattern, str(item))) for item in a)
26
39
 
27
40
 
28
41
  OPS: Dict[str, Callable[[Any, Any], bool]] = {
@@ -38,6 +51,8 @@ OPS: Dict[str, Callable[[Any, Any], bool]] = {
38
51
  "len_eq": op_len_eq,
39
52
  "in": op_in,
40
53
  "not_in": op_not_in,
54
+ "contains_all": op_contains_all,
55
+ "match_regex_all": op_match_regex_all,
41
56
  }
42
57
 
43
58
 
@@ -563,9 +563,15 @@ class Runner:
563
563
  step_failed = False
564
564
  for v in step.validators:
565
565
  rendered_check = self._render(v.check, variables, funcs, envmap)
566
- check_str = rendered_check if isinstance(rendered_check, str) else str(v.check)
566
+ # If rendered_check is not a string, it's already a value (e.g., extracted variable)
567
+ # Use it directly as actual instead of trying to resolve from response
568
+ if not isinstance(rendered_check, str):
569
+ actual = rendered_check
570
+ check_str = str(v.check)
571
+ else:
572
+ check_str = rendered_check
573
+ actual = self._resolve_check(check_str, resp_obj)
567
574
  expect_rendered = self._render(v.expect, variables, funcs, envmap)
568
- actual = self._resolve_check(check_str, resp_obj)
569
575
  passed, err = compare(v.comparator, actual, expect_rendered)
570
576
  msg = err
571
577
  if not passed and msg is None:
@@ -5,6 +5,7 @@ Drun 项目脚手架模板集合。
5
5
  """
6
6
 
7
7
  from .templates import (
8
+ ASSERTIONS_TESTCASE,
8
9
  CONVERTS_README,
9
10
  CSV_DATA_TESTCASE,
10
11
  CSV_DATA_TESTSUITE,
@@ -27,6 +28,7 @@ from .templates import (
27
28
  )
28
29
 
29
30
  __all__ = [
31
+ "ASSERTIONS_TESTCASE",
30
32
  "CONVERTS_README",
31
33
  "CSV_DATA_TESTCASE",
32
34
  "CSV_DATA_TESTSUITE",
@@ -1775,5 +1775,254 @@ steps:
1775
1775
  - gt: [$time_to_last, $time_to_first] # 末包晚于首包
1776
1776
  """
1777
1777
 
1778
+ # 断言完整示例用例模板
1779
+ ASSERTIONS_TESTCASE = """config:
1780
+ name: 断言操作符完整示例
1781
+ base_url: ${ENV(BASE_URL)}
1782
+ tags: [demo, assertions]
1783
+ variables:
1784
+ expected_username: test_user
1785
+ max_response_time: 2000
1786
+ min_items: 5
1787
+ search_keyword: embedding
1788
+
1789
+ steps:
1790
+ # ==================== 基础比较断言 ====================
1791
+ - name: 基础比较断言示例
1792
+ request:
1793
+ method: GET
1794
+ path: /get?page=1&limit=10
1795
+ headers:
1796
+ User-Agent: Drun-Test-Client
1797
+ extract:
1798
+ page_num: $.args.page
1799
+ limit_num: $.args.limit
1800
+ validate:
1801
+ # eq: 等于
1802
+ - eq: [status_code, 200]
1803
+ - eq: [$page_num, "1"]
1804
+ - eq: [$.args.limit, "10"]
1805
+
1806
+ # ne: 不等于
1807
+ - ne: [status_code, 404]
1808
+ - ne: [$limit_num, "20"]
1809
+
1810
+ # lt: 小于
1811
+ - lt: [$elapsed_ms, $max_response_time]
1812
+ - lt: [$page_num, "10"]
1813
+
1814
+ # le: 小于等于
1815
+ - le: [status_code, 299]
1816
+ - le: [$limit_num, "10"]
1817
+
1818
+ # gt: 大于
1819
+ - gt: [status_code, 100]
1820
+ - gt: [$elapsed_ms, 0]
1821
+
1822
+ # ge: 大于等于
1823
+ - ge: [status_code, 200]
1824
+ - ge: [$limit_num, "1"]
1825
+
1826
+ # ==================== 字符串断言 ====================
1827
+ - name: 字符串断言示例
1828
+ request:
1829
+ method: POST
1830
+ path: /anything
1831
+ headers:
1832
+ Content-Type: application/json
1833
+ User-Agent: Drun-Test-Client/v1.0
1834
+ body:
1835
+ username: $expected_username
1836
+ email: test@example.com
1837
+ description: This is a test user account
1838
+ extract:
1839
+ response_json: $.json
1840
+ user_agent: $.headers.User-Agent
1841
+ validate:
1842
+ # contains: 包含子字符串
1843
+ - contains: [headers.Content-Type, application/json]
1844
+ - contains: [$user_agent, Drun-Test-Client]
1845
+ - contains: [$.json.description, test user]
1846
+
1847
+ # not_contains: 不包含子字符串
1848
+ - not_contains: [$.json.username, admin]
1849
+ - not_contains: [$.json.email, .cn]
1850
+
1851
+ # regex: 正则表达式匹配
1852
+ - regex: [$.json.email, '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$']
1853
+ - regex: [$.json.username, '^[a-z_]+$']
1854
+ - regex: [$user_agent, 'Drun.*v\d+\.\d+']
1855
+
1856
+ # ==================== 集合与长度断言 ====================
1857
+ - name: 集合与长度断言示例
1858
+ request:
1859
+ method: POST
1860
+ path: /anything
1861
+ body:
1862
+ tags: [python, testing, automation, api]
1863
+ permissions: [read, write, delete]
1864
+ metadata:
1865
+ count: 42
1866
+ active: true
1867
+ extract:
1868
+ tags_list: $.json.tags
1869
+ permissions: $.json.permissions
1870
+ tag_count: $.json.tags
1871
+ first_permission: $.json.permissions[0]
1872
+ validate:
1873
+ # in: 值在列表中
1874
+ - in: [python, $tags_list]
1875
+ - in: [testing, $tags_list]
1876
+ - in:
1877
+ - $first_permission
1878
+ - [read, write, admin]
1879
+
1880
+ # not_in: 值不在列表中
1881
+ - not_in: [java, $tags_list]
1882
+ - not_in: [admin, $permissions]
1883
+
1884
+ # len_eq: 长度等于
1885
+ - len_eq: [$tags_list, 4]
1886
+ - len_eq: [$permissions, 3]
1887
+ - len_eq: [$.json.metadata, 2]
1888
+
1889
+ # ==================== 批量断言(列表)====================
1890
+ - name: 批量断言示例 - 模型列表
1891
+ # 注意:实际使用时应该用 JSONPath 从响应中提取列表
1892
+ # 例如:extract: { all_model_names: "$.data.items[*].model_name" }
1893
+ # 这里为了演示,使用预定义的变量
1894
+ variables:
1895
+ all_model_names:
1896
+ - Deepexi-Embedding-V1
1897
+ - Deepexi-Embedding-V2
1898
+ - Deepexi-Embedding-V3
1899
+ all_model_ids:
1900
+ - emb-001
1901
+ - emb-002
1902
+ - emb-003
1903
+ request:
1904
+ method: GET
1905
+ path: /anything/models
1906
+ params:
1907
+ category: embedding
1908
+ vendor: deepexi
1909
+ validate:
1910
+ - eq: [status_code, 200]
1911
+
1912
+ # contains_all: 列表中所有元素都包含指定字符串
1913
+ - contains_all: [$all_model_names, Deepexi]
1914
+ - contains_all: [$all_model_names, Embedding]
1915
+ - contains_all: [$all_model_ids, emb-]
1916
+
1917
+ # match_regex_all: 列表中所有元素都匹配正则表达式
1918
+ - match_regex_all: [$all_model_names, '^Deepexi-Embedding-V\d+$']
1919
+ - match_regex_all: [$all_model_ids, '^emb-\d{3}$']
1920
+
1921
+ # ==================== 复杂场景组合 ====================
1922
+ - name: 复杂场景组合示例
1923
+ request:
1924
+ method: GET
1925
+ path: /get
1926
+ params:
1927
+ search: $search_keyword
1928
+ page: 1
1929
+ page_size: 20
1930
+ headers:
1931
+ Accept: application/json
1932
+ X-Request-ID: ${short_uid(16)}
1933
+ extract:
1934
+ items_list: $.args
1935
+ request_id: $.headers.X-Request-ID
1936
+ validate:
1937
+ # 状态码检查
1938
+ - eq: [status_code, 200]
1939
+ - ge: [status_code, 200]
1940
+ - lt: [status_code, 300]
1941
+
1942
+ # 响应时间检查
1943
+ - lt: [$elapsed_ms, 3000]
1944
+ - gt: [$elapsed_ms, 0]
1945
+
1946
+ # Content-Type 检查
1947
+ - contains: [headers.Content-Type, application/json]
1948
+ - not_contains: [headers.Content-Type, text/html]
1949
+
1950
+ # 参数回显检查
1951
+ - eq: [$.args.search, $search_keyword]
1952
+ - regex: [$.args.page, '^\d+$']
1953
+ - regex: [$.args.page_size, '^\d+$']
1954
+
1955
+ # Request ID 格式检查
1956
+ - regex: [$request_id, '^[a-f0-9]{16}$']
1957
+ - len_eq: [$request_id, 16]
1958
+
1959
+ # ==================== JSONPath 提取 + 批量断言 ====================
1960
+ - name: JSONPath 提取 + 批量断言
1961
+ # 注意:实际使用时应该用 JSONPath 从响应中提取列表
1962
+ # 例如:extract: { all_usernames: "$.json.users[*].username" }
1963
+ # 这里为了演示,使用预定义的变量模拟提取的数据
1964
+ variables:
1965
+ # 模拟从响应中提取的用户名列表
1966
+ all_usernames:
1967
+ - alice
1968
+ - bob
1969
+ - carol
1970
+ # 模拟从响应中提取的邮箱列表
1971
+ all_emails:
1972
+ - alice@example.com
1973
+ - bob@example.com
1974
+ - carol@example.com
1975
+ request:
1976
+ method: POST
1977
+ path: /anything/api/users
1978
+ body:
1979
+ users:
1980
+ - username: alice
1981
+ email: alice@example.com
1982
+ role: admin
1983
+ - username: bob
1984
+ email: bob@example.com
1985
+ role: member
1986
+ - username: carol
1987
+ email: carol@example.com
1988
+ role: member
1989
+ validate:
1990
+ - eq: [status_code, 200]
1991
+
1992
+ # 验证所有用户名都是小写字母
1993
+ - match_regex_all: [$all_usernames, '^[a-z]+$']
1994
+
1995
+ # 验证所有邮箱格式正确
1996
+ - match_regex_all: [$all_emails, '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$']
1997
+
1998
+ # 验证所有邮箱都来自 example.com 域
1999
+ - contains_all: [$all_emails, example.com]
2000
+
2001
+ # 验证列表长度
2002
+ - len_eq: [$all_usernames, 3]
2003
+ - len_eq: [$all_emails, 3]
2004
+
2005
+ # ==================== 性能和负值断言 ====================
2006
+ - name: 性能和边界值断言
2007
+ request:
2008
+ method: GET
2009
+ path: /delay/0
2010
+ extract:
2011
+ response_time: $elapsed_ms
2012
+ validate:
2013
+ - eq: [status_code, 200]
2014
+
2015
+ # 性能断言
2016
+ - lt: [$response_time, 1000] # 响应时间 < 1秒
2017
+ - gt: [$response_time, 0] # 响应时间 > 0
2018
+ - le: [$response_time, 1000] # 响应时间 <= 1秒
2019
+ - ge: [$response_time, 0] # 响应时间 >= 0
2020
+
2021
+ # 状态码范围
2022
+ - ge: [status_code, 200]
2023
+ - lt: [status_code, 300]
2024
+ - ne: [status_code, 204]
2025
+ """
2026
+
1778
2027
  # .gitkeep 文件内容(用于保留空目录)
1779
2028
  GITKEEP_CONTENT = "# This file keeps the directory in version control\n"
@@ -5,12 +5,18 @@ from typing import Any, Dict
5
5
 
6
6
 
7
7
  def to_curl(method: str, path: str, *, headers: Dict[str, str] | None = None, data: Any | None = None) -> str:
8
- """Build a curl command string.
8
+ """Build a curl command string with multi-line formatting.
9
9
 
10
10
  - Uses --data-raw for request body to keep payload untouched.
11
11
  - Pretty prints JSON body with indent=2 when data is dict/list for readability.
12
+ - Formats output with each parameter on a separate line using backslash continuation.
12
13
  """
13
- parts = ["curl", "-X", method.upper(), shlex.quote(path)]
14
+ lines = ["curl \\"]
15
+
16
+ # Method and URL
17
+ lines.append(f" -X {method.upper()} \\")
18
+ lines.append(f" {shlex.quote(path)} \\")
19
+
14
20
  # Prepare headers (case-insensitive handling)
15
21
  hdrs: Dict[str, str] = dict(headers or {})
16
22
  has_ct = any(k.lower() == "content-type" for k in hdrs.keys())
@@ -21,14 +27,22 @@ def to_curl(method: str, path: str, *, headers: Dict[str, str] | None = None, da
21
27
  is_json_like = s.startswith("{") or s.startswith("[")
22
28
  if is_json_like:
23
29
  hdrs["Content-Type"] = "application/json"
30
+
31
+ # Headers (each on separate line)
24
32
  for k, v in hdrs.items():
25
- parts += ["-H", shlex.quote(f"{k}: {v}")]
33
+ lines.append(f" -H {shlex.quote(f'{k}: {v}')} \\")
34
+
35
+ # Data (if exists)
26
36
  if data is not None:
27
37
  if isinstance(data, (dict, list)):
28
38
  import json
29
39
  payload = json.dumps(data, ensure_ascii=False, indent=2)
30
40
  else:
31
41
  payload = str(data)
32
- # Prefer --data-raw to avoid implicit transformations
33
- parts += ["--data-raw", shlex.quote(payload)]
34
- return " ".join(parts)
42
+ # Last line without backslash
43
+ lines.append(f" --data-raw {shlex.quote(payload)}")
44
+ else:
45
+ # Remove trailing backslash from last line
46
+ lines[-1] = lines[-1].rstrip(' \\')
47
+
48
+ return '\n'.join(lines)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drun
3
- Version: 2.1.2
3
+ Version: 2.2.2
4
4
  Summary: Minimal HTTP API test runner (MVP)
5
5
  Author: Drun Team
6
6
  Requires-Python: >=3.10
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "drun"
7
- version = "2.1.2"
7
+ version = "2.2.2"
8
8
  description = "Minimal HTTP API test runner (MVP)"
9
9
  requires-python = ">=3.10"
10
10
  authors = [{ name = "Drun Team" }]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes