mikeboone Claude Sonnet 4.6 commited on
Commit
898ea03
Β·
1 Parent(s): 2d56439

fix: prevent NULL inserts for NOT NULL columns (AIRCRAFT/SEAT_CAPACITY_TOTAL pattern)

Browse files

Two root-cause fixes in generator.py:
1. FK block: `continue` now only fires when fk_ref found AND get_fk_value returns non-None;
previously always fired, leaving unset columns as None if referenced table was empty
2. Coherent entity block: if AI returns null for any column, fall through to generic
generation instead of assigning None (was only handling numeric columns, not type-unknown)

Bridge safety net: convert_value now returns 0.0 instead of None for numeric columns
that receive unparseable strings β€” satisfies NOT NULL without crashing

Also includes e2e_quality.py improvements: late_complete status, liveboard links
section, --env-name arg, save_summary_md, company name in table display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

legitdata_bridge.py CHANGED
@@ -264,12 +264,12 @@ class KeyPairSnowflakeWriter:
264
  import re as _re
265
  cleaned = _re.sub(r'[^\d.\-+eE]', '', value.replace(',', ''))
266
  try:
267
- value = float(cleaned) if cleaned else None
268
  except (ValueError, TypeError):
269
- return None # Can't coerce β€” use NULL rather than crash
270
  elif not isinstance(value, (int, float)):
271
- # dict, list, or other non-numeric type β€” cannot be cast; use NULL
272
- return None
273
  precision = info.get('precision', 38)
274
  scale = info.get('scale', 0)
275
  if isinstance(value, (int, float)):
 
264
  import re as _re
265
  cleaned = _re.sub(r'[^\d.\-+eE]', '', value.replace(',', ''))
266
  try:
267
+ value = float(cleaned) if cleaned else 0.0
268
  except (ValueError, TypeError):
269
+ value = 0.0 # Can't coerce β€” use 0 rather than NULL to satisfy NOT NULL
270
  elif not isinstance(value, (int, float)):
271
+ # dict, list, or other non-numeric type β€” use 0 rather than NULL
272
+ value = 0.0
273
  precision = info.get('precision', 38)
274
  scale = info.get('scale', 0)
275
  if isinstance(value, (int, float)):
legitdata_project/legitdata/generator.py CHANGED
@@ -991,41 +991,53 @@ class LegitGenerator:
991
  fk_ref = fk
992
  break
993
  if fk_ref:
994
- row[col_name] = self.fk_manager.get_fk_value(
995
  fk_ref.references_table,
996
  fk_ref.references_column,
997
  distribution="pareto" if table.is_fact_table else "uniform"
998
  )
999
- continue
1000
-
 
 
 
 
 
 
1001
  # Coherent entity columns - get from pre-generated entities
1002
  if coherent_entities and i < len(coherent_entities):
1003
  entity = coherent_entities[i]
1004
  if col_name in entity:
1005
  entity_value = entity[col_name]
1006
- # Check if column is numeric but AI gave us text
1007
- data_type_upper = (column.data_type or '').upper()
1008
- is_numeric_col = any(t in data_type_upper for t in (
1009
- 'INT', 'NUMBER', 'NUMERIC', 'DECIMAL', 'BIGINT', 'SMALLINT',
1010
- 'FLOAT', 'REAL', 'DOUBLE', 'FIXED', 'MONEY',
1011
- ))
1012
-
1013
- if is_numeric_col:
1014
- # Coerce to float and store as Python numeric, not string.
1015
- # Strip currency symbols / units before trying.
1016
- import re as _re
1017
- cleaned = _re.sub(r'[^\d.\-+eE]', '', str(entity_value).replace(',', ''))
1018
- try:
1019
- row[col_name] = float(cleaned)
1020
- continue
1021
- except (ValueError, TypeError):
1022
- # AI gave us unparseable text β€” fall through to generic
1023
- if i == 0:
1024
- print(f" [WARN] {col_name}: AI gave '{entity_value}' for numeric column, using inferred instead")
1025
- pass # Fall through to generic generation below
1026
  else:
1027
- row[col_name] = entity_value
1028
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1029
 
1030
  # GENERIC columns - use classification strategy if set, otherwise infer
1031
  col_class = table_class.get(column.name, {})
 
991
  fk_ref = fk
992
  break
993
  if fk_ref:
994
+ fk_value = self.fk_manager.get_fk_value(
995
  fk_ref.references_table,
996
  fk_ref.references_column,
997
  distribution="pareto" if table.is_fact_table else "uniform"
998
  )
999
+ if fk_value is not None:
1000
+ row[col_name] = fk_value
1001
+ continue
1002
+ # FK lookup returned None (referenced table empty) β€” fall through to generic
1003
+ if i == 0:
1004
+ print(f" [WARN] {col_name}: FK lookup returned None for {fk_ref.references_table}.{fk_ref.references_column}, using generic fallback")
1005
+ # fk_ref not found or FK returned None β€” fall through to generic
1006
+
1007
  # Coherent entity columns - get from pre-generated entities
1008
  if coherent_entities and i < len(coherent_entities):
1009
  entity = coherent_entities[i]
1010
  if col_name in entity:
1011
  entity_value = entity[col_name]
1012
+ # If AI returned null for this column, fall through to generic generation
1013
+ if entity_value is None:
1014
+ if i == 0:
1015
+ print(f" [WARN] {col_name}: AI returned null, using generic fallback")
1016
+ # Fall through to generic generation below
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1017
  else:
1018
+ # Check if column is numeric but AI gave us text
1019
+ data_type_upper = (column.data_type or '').upper()
1020
+ is_numeric_col = any(t in data_type_upper for t in (
1021
+ 'INT', 'NUMBER', 'NUMERIC', 'DECIMAL', 'BIGINT', 'SMALLINT',
1022
+ 'FLOAT', 'REAL', 'DOUBLE', 'FIXED', 'MONEY',
1023
+ ))
1024
+
1025
+ if is_numeric_col:
1026
+ # Coerce to float and store as Python numeric, not string.
1027
+ # Strip currency symbols / units before trying.
1028
+ import re as _re
1029
+ cleaned = _re.sub(r'[^\d.\-+eE]', '', str(entity_value).replace(',', ''))
1030
+ try:
1031
+ row[col_name] = float(cleaned)
1032
+ continue
1033
+ except (ValueError, TypeError):
1034
+ # AI gave us unparseable text β€” fall through to generic
1035
+ if i == 0:
1036
+ print(f" [WARN] {col_name}: AI gave '{entity_value}' for numeric column, using inferred instead")
1037
+ # Fall through to generic generation below
1038
+ else:
1039
+ row[col_name] = entity_value
1040
+ continue
1041
 
1042
  # GENERIC columns - use classification strategy if set, otherwise infer
1043
  col_class = table_class.get(column.name, {})
tests/e2e_quality.py CHANGED
@@ -1085,6 +1085,41 @@ def grade_stages(stages: dict, config: dict) -> dict:
1085
  return {"stage_total": total, "breakdown": breakdown}
1086
 
1087
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1088
  def compute_grade(score: float, config: dict) -> str:
1089
  grade = "F"
1090
  for letter, threshold in sorted(config["grading"]["thresholds"].items(), key=lambda x: -x[1]):
@@ -1300,7 +1335,7 @@ def run_single_test(page: Page, test_case: dict, config: dict) -> dict:
1300
  "function": test_case.get("function", ""), "company_url": test_case.get("company_url", ""),
1301
  "stages": {}, "run_context": {}, "stage_grading": {}, "ai_grading": {},
1302
  "total_score": 0.0, "grade": "F",
1303
- "error": None, "timed_out": False, "duration_seconds": 0,
1304
  "diagnostics": {}, "snowflake_check": {},
1305
  "liveboard_viz_count": None,
1306
  }
@@ -1402,8 +1437,16 @@ def run_single_test(page: Page, test_case: dict, config: dict) -> dict:
1402
  else:
1403
  print(f" πŸ“¦ Snowflake: schema not found (logged as: {known_schema or 'not in logs'})")
1404
 
1405
- # Print diagnostics on timeout or error
1406
- if result["timed_out"] or result["error"]:
 
 
 
 
 
 
 
 
1407
  print_diagnostics(diag, {}) # sf already printed above
1408
 
1409
  # Stage scoring
@@ -1441,10 +1484,85 @@ def save_results(run: dict) -> Path:
1441
  return path
1442
 
1443
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1444
  # ---------------------------------------------------------------------------
1445
  # Main
1446
  # ---------------------------------------------------------------------------
1447
- def run_quality_suite(max_tests: int = None):
1448
  if not TEST_USER or not TEST_PASSWORD:
1449
  raise RuntimeError("TEST_USER and TEST_PASSWORD must be set in .env")
1450
 
@@ -1508,9 +1626,15 @@ def run_quality_suite(max_tests: int = None):
1508
  viz_n = result.get("liveboard_viz_count")
1509
  viz_note = f" ({viz_n} vizzes)" if viz_n is not None else ""
1510
  print(f" Liveboard: {ts_url}/#/pinboard/{l_guid}{viz_note}")
 
 
 
 
 
 
1511
  print(f" TOTAL: {result['total_score']}/100 Grade: {result['grade']}"
1512
  f" ({result['duration_seconds']}s)"
1513
- f"{' ⏰ TIMEOUT' if result['timed_out'] else ''}"
1514
  f"{' ❌ ERROR' if result['error'] else ''}")
1515
 
1516
  ctx.close()
@@ -1521,10 +1645,12 @@ def run_quality_suite(max_tests: int = None):
1521
 
1522
  run = {
1523
  "run_id": run_id, "timestamp": datetime.now().isoformat(),
 
1524
  "avg_score": avg, "overall_grade": grade,
1525
  "test_count": len(results), "tests": results,
1526
  }
1527
- save_results(run)
 
1528
 
1529
  # --- Summary table ---
1530
  try:
@@ -1561,18 +1687,33 @@ def run_quality_suite(max_tests: int = None):
1561
  lb_url, lb_base = ctx.get("liveboard_guid",""), ctx.get("ts_base_url","")
1562
  lk = _link(f"{lb_base}/#/pinboard/{lb_url}", " πŸ“‹ ") if lb_url and lb_base else " β€” "
1563
  parts = [p for p in [r.get("vertical",""), r.get("line",""), r.get("function","")] if p and p != "* CUSTOM *"]
1564
- uc = (" / ".join(parts) if parts else r.get("type","").replace("_"," ").title())[:W_UC]
1565
  errs = ag.get("grading_errors", [])
1566
- if r.get("timed_out"): note = "⏰ timeout"
1567
- elif any("parse" in (e or "").lower() for e in errs): note = "❌ parse fail"
1568
- elif ds == 0 and ls is not None: note = "⚠️ data fail"
1569
- elif r["grade"] in ("A","B"): note = "πŸ† great"
1570
- elif r["grade"] == "C": note = "βœ… solid"
1571
- else: note = ""
1572
- print(_row(r["name"][:W_CO], uc, d_str, l_str, t_str, lk, note))
 
 
 
 
1573
 
1574
  print(_div("β””", "β”΄", "β”˜"))
1575
- print(f"{'='*62}\n")
 
 
 
 
 
 
 
 
 
 
 
1576
 
1577
  except Exception as _table_err:
1578
  print(f"\n⚠️ Summary table failed: {_table_err}")
@@ -1601,6 +1742,8 @@ if __name__ == "__main__":
1601
  help="Fill form but do not click GO β€” browser opens visibly for inspection")
1602
  parser.add_argument("--url", type=str, default="",
1603
  help="Override TEST_TARGET_URL (e.g. --url https://thoughtspot-dp-demoprep.hf.space)")
 
 
1604
  args = parser.parse_args()
1605
  if args.dry_run:
1606
  DRY_RUN = True
@@ -1608,4 +1751,7 @@ if __name__ == "__main__":
1608
  BASE_URL = args.url
1609
  if not BASE_URL:
1610
  raise ValueError("No target URL β€” set TEST_TARGET_URL in .env or pass --url <url>")
1611
- run_quality_suite(max_tests=args.count or (1 if args.dry_run else None))
 
 
 
 
1085
  return {"stage_total": total, "breakdown": breakdown}
1086
 
1087
 
1088
+ def reconcile_stages_with_logs(stages: dict, diag: dict) -> dict:
1089
+ """
1090
+ If session_logs confirms stages completed that the UI monitor missed
1091
+ (e.g. slow DDL that triggered the 20-min bail-out), upgrade those stages to 'complete'.
1092
+ Returns a new dict β€” does not mutate the original.
1093
+ """
1094
+ if not diag.get("found"):
1095
+ return stages
1096
+
1097
+ completed_in_logs = set(diag.get("stages_completed", []))
1098
+
1099
+ # Map session_log stage names β†’ UI progress keys
1100
+ log_to_ui = {
1101
+ "research": "research",
1102
+ "ddl": "ddl",
1103
+ "populate": "data", # session calls it 'populate', UI shows 'Data'
1104
+ "data": "data",
1105
+ "thoughtspot": "thoughtspot",
1106
+ }
1107
+
1108
+ stages = dict(stages) # copy β€” don't mutate
1109
+ for log_stage, ui_key in log_to_ui.items():
1110
+ if log_stage in completed_in_logs and stages.get(ui_key) != "complete":
1111
+ old = stages.get(ui_key, "unknown")
1112
+ stages[ui_key] = "complete"
1113
+ print(f" ℹ️ Stage '{ui_key}' upgraded to complete via session_logs (monitor saw: {old})")
1114
+
1115
+ # Synthetic 'complete' key β€” set if all main stages now complete
1116
+ main = ("research", "ddl", "data", "thoughtspot")
1117
+ if all(stages.get(s) == "complete" for s in main):
1118
+ stages["complete"] = "complete"
1119
+
1120
+ return stages
1121
+
1122
+
1123
  def compute_grade(score: float, config: dict) -> str:
1124
  grade = "F"
1125
  for letter, threshold in sorted(config["grading"]["thresholds"].items(), key=lambda x: -x[1]):
 
1335
  "function": test_case.get("function", ""), "company_url": test_case.get("company_url", ""),
1336
  "stages": {}, "run_context": {}, "stage_grading": {}, "ai_grading": {},
1337
  "total_score": 0.0, "grade": "F",
1338
+ "error": None, "timed_out": False, "late_complete": False, "duration_seconds": 0,
1339
  "diagnostics": {}, "snowflake_check": {},
1340
  "liveboard_viz_count": None,
1341
  }
 
1437
  else:
1438
  print(f" πŸ“¦ Snowflake: schema not found (logged as: {known_schema or 'not in logs'})")
1439
 
1440
+ # Reconcile stages with session_logs β€” catches cases where the monitor bailed early
1441
+ # but the pipeline actually completed (e.g. slow DDL that took >20 min)
1442
+ result["stages"] = reconcile_stages_with_logs(result["stages"], diag)
1443
+ main_stages = ("research", "ddl", "data", "thoughtspot")
1444
+ if result["timed_out"] and all(result["stages"].get(s) == "complete" for s in main_stages):
1445
+ result["late_complete"] = True
1446
+ print(" ℹ️ Pipeline completed late β€” all stages confirmed via session_logs")
1447
+
1448
+ # Print diagnostics on timeout/error β€” skip for late_complete since pipeline did finish
1449
+ if result["error"] or (result["timed_out"] and not result["late_complete"]):
1450
  print_diagnostics(diag, {}) # sf already printed above
1451
 
1452
  # Stage scoring
 
1484
  return path
1485
 
1486
 
1487
+ def save_summary_md(run: dict, json_path: Path, env_name: str = "") -> Path:
1488
+ """
1489
+ Save a compact markdown summary alongside the JSON.
1490
+ Also writes to latest_{env_name}_summary.md (or latest_summary.md) for nightly use.
1491
+ env_name: 'test' | 'prod' | '' (default)
1492
+ """
1493
+ W = 32
1494
+ lines = [
1495
+ f"# DemoPrep Quality Run β€” {run['timestamp'][:16]}",
1496
+ f"**Target:** {run.get('target_url', 'unknown')} | "
1497
+ f"**Avg:** {run['avg_score']}/100 Grade: {run['overall_grade']}",
1498
+ "",
1499
+ "| Company | Use Case | Data | LB | Total | Note |",
1500
+ "|---------|----------|------|----|-------|------|",
1501
+ ]
1502
+ for r in run["tests"]:
1503
+ ag = r.get("ai_grading", {})
1504
+ ds = ag.get("data_score", "n/a")
1505
+ ls = ag.get("liveboard_score", "n/a")
1506
+ t_str = f"{r['total_score']}/{r['grade']}"
1507
+ company = r.get("company", r["name"])
1508
+ parts = [p for p in [r.get("vertical",""), r.get("line",""), r.get("function","")]
1509
+ if p and p != "* CUSTOM *"]
1510
+ uc = (" / ".join(parts) if parts else "Custom")[:W]
1511
+ if r.get("error"): note = "❌ network err"
1512
+ elif r.get("late_complete"): note = "⚠️ slow (complete)"
1513
+ elif r.get("timed_out"): note = "⏰ timeout"
1514
+ elif r["grade"] in ("A","B"): note = "πŸ† great"
1515
+ elif r["grade"] == "C": note = "βœ… solid"
1516
+ else: note = ""
1517
+ ctx = r.get("run_context") or {}
1518
+ lb_guid = ctx.get("liveboard_guid","")
1519
+ lb_base = (ctx.get("ts_base_url","") or "").rstrip("/")
1520
+ lb_link = f"[lb]({lb_base}/#/pinboard/{lb_guid})" if lb_guid and lb_base else "β€”"
1521
+ lines.append(f"| {company} | {uc} | {ds} | {ls} | {t_str} {lb_link} | {note} |")
1522
+
1523
+ # Issues and errors
1524
+ issues = []
1525
+ for r in run["tests"]:
1526
+ if r.get("timed_out") and not r.get("late_complete"):
1527
+ last = r.get("diagnostics",{}).get("last_event","unknown")
1528
+ issues.append(f"- **{r.get('company',r['name'])}**: TIMEOUT β€” last event: {last}")
1529
+ for err in r.get("ai_grading",{}).get("grading_errors",[]):
1530
+ if "skip" not in err.lower():
1531
+ issues.append(f"- **{r.get('company',r['name'])}**: {err}")
1532
+ if issues:
1533
+ lines += ["", "## Issues", ""] + issues
1534
+
1535
+ # Top data weaknesses
1536
+ weaknesses = []
1537
+ for r in run["tests"]:
1538
+ ag = r.get("ai_grading",{})
1539
+ ww = ag.get("data_weaknesses",[])
1540
+ if ww:
1541
+ weaknesses.append(f"**{r.get('company',r['name'])}** (data={ag.get('data_score','?')}/100):")
1542
+ for w in ww[:2]:
1543
+ weaknesses.append(f" - {w[:120]}")
1544
+ if weaknesses:
1545
+ lines += ["", "## Data Quality Weaknesses", ""] + weaknesses
1546
+
1547
+ lines += ["", "---", f"*JSON: {json_path.name}*"]
1548
+
1549
+ md_text = "\n".join(lines) + "\n"
1550
+ md_path = json_path.with_suffix(".md")
1551
+ md_path.write_text(md_text)
1552
+
1553
+ latest_name = f"latest_{env_name}_summary.md" if env_name else "latest_summary.md"
1554
+ latest = RESULTS_DIR / latest_name
1555
+ latest.write_text(md_text)
1556
+
1557
+ print(f"πŸ“‹ Summary: {md_path}")
1558
+ print(f"πŸ“‹ Latest: {latest}")
1559
+ return md_path
1560
+
1561
+
1562
  # ---------------------------------------------------------------------------
1563
  # Main
1564
  # ---------------------------------------------------------------------------
1565
+ def run_quality_suite(max_tests: int = None, env_name: str = ""):
1566
  if not TEST_USER or not TEST_PASSWORD:
1567
  raise RuntimeError("TEST_USER and TEST_PASSWORD must be set in .env")
1568
 
 
1626
  viz_n = result.get("liveboard_viz_count")
1627
  viz_note = f" ({viz_n} vizzes)" if viz_n is not None else ""
1628
  print(f" Liveboard: {ts_url}/#/pinboard/{l_guid}{viz_note}")
1629
+ if result.get("late_complete"):
1630
+ timeout_tag = " ⚠️ SLOW (completed late)"
1631
+ elif result.get("timed_out"):
1632
+ timeout_tag = " ⏰ TIMEOUT"
1633
+ else:
1634
+ timeout_tag = ""
1635
  print(f" TOTAL: {result['total_score']}/100 Grade: {result['grade']}"
1636
  f" ({result['duration_seconds']}s)"
1637
+ f"{timeout_tag}"
1638
  f"{' ❌ ERROR' if result['error'] else ''}")
1639
 
1640
  ctx.close()
 
1645
 
1646
  run = {
1647
  "run_id": run_id, "timestamp": datetime.now().isoformat(),
1648
+ "target_url": BASE_URL,
1649
  "avg_score": avg, "overall_grade": grade,
1650
  "test_count": len(results), "tests": results,
1651
  }
1652
+ path = save_results(run)
1653
+ save_summary_md(run, path, env_name=env_name)
1654
 
1655
  # --- Summary table ---
1656
  try:
 
1687
  lb_url, lb_base = ctx.get("liveboard_guid",""), ctx.get("ts_base_url","")
1688
  lk = _link(f"{lb_base}/#/pinboard/{lb_url}", " πŸ“‹ ") if lb_url and lb_base else " β€” "
1689
  parts = [p for p in [r.get("vertical",""), r.get("line",""), r.get("function","")] if p and p != "* CUSTOM *"]
1690
+ uc = (" / ".join(parts) if parts else r.get("context","")[:W_UC] or "Custom")[:W_UC]
1691
  errs = ag.get("grading_errors", [])
1692
+ if r.get("error"): note = "❌ network err"
1693
+ elif r.get("late_complete"): note = "⚠️ slow"
1694
+ elif r.get("timed_out"): note = "⏰ timeout"
1695
+ elif any("auth failed" in (e or "").lower() for e in errs): note = "❌ auth fail"
1696
+ elif any("parse" in (e or "").lower() for e in errs): note = "❌ parse fail"
1697
+ elif ds == 0 and ls is not None: note = "⚠️ data fail"
1698
+ elif r["grade"] in ("A","B"): note = "πŸ† great"
1699
+ elif r["grade"] == "C": note = "βœ… solid"
1700
+ else: note = ""
1701
+ company = r.get("company", r["name"])[:W_CO]
1702
+ print(_row(company, uc, d_str, l_str, t_str, lk, note))
1703
 
1704
  print(_div("β””", "β”΄", "β”˜"))
1705
+
1706
+ # Liveboard links β€” plain text for easy copy/click
1707
+ print("\n Liveboards:")
1708
+ for i, r in enumerate(results, 1):
1709
+ ctx = r.get("run_context") or {}
1710
+ lb_url = ctx.get("liveboard_guid", "")
1711
+ lb_base = ctx.get("ts_base_url", "")
1712
+ company = r.get("company", r["name"])
1713
+ url = f"{lb_base}/#/pinboard/{lb_url}" if lb_url and lb_base else "β€” not created"
1714
+ print(f" {i}. {company:<30} {url}")
1715
+
1716
+ print(f"\n{'='*62}\n")
1717
 
1718
  except Exception as _table_err:
1719
  print(f"\n⚠️ Summary table failed: {_table_err}")
 
1742
  help="Fill form but do not click GO β€” browser opens visibly for inspection")
1743
  parser.add_argument("--url", type=str, default="",
1744
  help="Override TEST_TARGET_URL (e.g. --url https://thoughtspot-dp-demoprep.hf.space)")
1745
+ parser.add_argument("--env-name", type=str, default="",
1746
+ help="Tag for summary filename: 'test' β†’ latest_test_summary.md, 'prod' β†’ latest_prod_summary.md")
1747
  args = parser.parse_args()
1748
  if args.dry_run:
1749
  DRY_RUN = True
 
1751
  BASE_URL = args.url
1752
  if not BASE_URL:
1753
  raise ValueError("No target URL β€” set TEST_TARGET_URL in .env or pass --url <url>")
1754
+ run_quality_suite(
1755
+ max_tests=args.count or (1 if args.dry_run else None),
1756
+ env_name=args.env_name,
1757
+ )