Skip to content

Commit 843fc4b

Browse files
committed
Add stable je_auto_control.api facade and portable failure bundles
New integrations get a small, lazy, typed entry point instead of the eager historical top-level surface. Failed runs produce one atomic, redacted autocontrol.failure-bundle/v1 ZIP (manifest, context, events, log tail, optional screenshot/diagnostics) with best-effort collectors so a broken screen grab cannot lose the bundle. codegen --failure-bundle wraps generated pytest in automatic diagnostics; the secret redactor now masks explicit key=value and bearer-token syntax regardless of entropy.
1 parent ed66db2 commit 843fc4b

11 files changed

Lines changed: 434 additions & 13 deletions

File tree

benchmarks/core_latency.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Repeatable smoke benchmark for stable headless entry points."""
2+
import json
3+
import statistics
4+
import time
5+
6+
7+
def _measure(callable_, repeats=20):
8+
samples = []
9+
for _ in range(repeats):
10+
start = time.perf_counter()
11+
callable_()
12+
samples.append((time.perf_counter() - start) * 1000)
13+
ordered = sorted(samples)
14+
return {
15+
"median_ms": round(statistics.median(samples), 3),
16+
"p95_ms": round(ordered[max(0, int(len(ordered) * .95) - 1)], 3),
17+
}
18+
19+
20+
def main():
21+
import je_auto_control.api as ac
22+
results = {
23+
"diagnostics_ms": _measure(ac.run_diagnostics, repeats=5),
24+
"codegen_ms": _measure(
25+
lambda: ac.generate_code([["AC_screen_size"]]), repeats=20),
26+
}
27+
print(json.dumps(results, indent=2, sort_keys=True))
28+
29+
30+
if __name__ == "__main__":
31+
main()

je_auto_control/api/__init__.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Small, versioned entry points for new integrations.
2+
3+
The historical top-level package remains compatible. New consumers should
4+
prefer this namespace so importing core automation does not eagerly import
5+
hundreds of optional integrations.
6+
"""
7+
8+
from je_auto_control.api.core import (
9+
FailureBundleOptions,
10+
create_failure_bundle,
11+
execute_action,
12+
execute_action_with_vars,
13+
generate_code,
14+
failure_bundle_on_error,
15+
run_diagnostics,
16+
)
17+
18+
__all__ = [
19+
"FailureBundleOptions", "create_failure_bundle", "execute_action",
20+
"execute_action_with_vars", "failure_bundle_on_error", "generate_code",
21+
"run_diagnostics",
22+
]

je_auto_control/api/core.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Stable, headless AutoControl API façade."""
2+
3+
from je_auto_control.utils.codegen.codegen import generate_code
4+
from je_auto_control.utils.diagnostics import run_diagnostics
5+
from je_auto_control.utils.executor.action_executor import (
6+
execute_action,
7+
execute_action_with_vars,
8+
)
9+
from je_auto_control.utils.failure_bundle import (
10+
FailureBundleOptions,
11+
create_failure_bundle,
12+
failure_bundle_on_error,
13+
)
14+
15+
__all__ = [
16+
"FailureBundleOptions", "create_failure_bundle", "execute_action",
17+
"execute_action_with_vars", "failure_bundle_on_error", "generate_code",
18+
"run_diagnostics",
19+
]

je_auto_control/cli.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
je_auto_control fmt script.json [--check]
1212
je_auto_control record out.json [--duration 5]
1313
je_auto_control codegen script.json [--target pytest] [-o test_flow.py]
14+
je_auto_control failure-bundle failure.zip [--error "message"]
1415
je_auto_control version
1516
je_auto_control list-jobs
1617
je_auto_control start-server --port 9938
@@ -147,11 +148,13 @@ def cmd_codegen(args: argparse.Namespace) -> int:
147148
from je_auto_control.utils.json.json_file import read_action_json
148149
if args.output:
149150
generate_code_file(args.script, args.output, target=args.target,
150-
name=args.name, style=args.style)
151+
name=args.name, style=args.style,
152+
failure_bundle=args.failure_bundle)
151153
sys.stderr.write(f"Wrote {args.target} code to {args.output}\n")
152154
else:
153155
code = generate_code(read_action_json(args.script), target=args.target,
154-
name=args.name, style=args.style)
156+
name=args.name, style=args.style,
157+
failure_bundle=args.failure_bundle)
155158
sys.stdout.write(code)
156159
return 0
157160

@@ -166,6 +169,25 @@ def cmd_version(_: argparse.Namespace) -> int:
166169
return 0
167170

168171

172+
def cmd_failure_bundle(args: argparse.Namespace) -> int:
173+
"""Collect a portable, redacted diagnostic archive."""
174+
from je_auto_control.utils.failure_bundle import (
175+
FailureBundleOptions, create_failure_bundle,
176+
)
177+
context = json.loads(args.context) if args.context else {}
178+
path = create_failure_bundle(
179+
args.output, error=args.error, context=context,
180+
options=FailureBundleOptions(
181+
screenshot=not args.no_screenshot,
182+
diagnostics=not args.no_diagnostics,
183+
log_path=args.log,
184+
attachments=tuple(args.attach or ()),
185+
),
186+
)
187+
sys.stdout.write(path + "\n")
188+
return 0
189+
190+
169191
def cmd_list_jobs(_: argparse.Namespace) -> int:
170192
from je_auto_control.utils.scheduler.scheduler import default_scheduler
171193
jobs = default_scheduler.list_jobs()
@@ -253,11 +275,26 @@ def build_parser() -> argparse.ArgumentParser:
253275
default="calls")
254276
p_codegen.add_argument("--name", default="recorded_flow")
255277
p_codegen.add_argument("-o", "--output", help="Write to file instead of stdout")
278+
p_codegen.add_argument(
279+
"--failure-bundle", action="store_true",
280+
help="Wrap generated pytest in automatic failure diagnostics")
256281
p_codegen.set_defaults(func=cmd_codegen)
257282

258283
p_version = sub.add_parser("version", help="Print the installed version")
259284
p_version.set_defaults(func=cmd_version)
260285

286+
p_bundle = sub.add_parser(
287+
"failure-bundle", help="Create a redacted failure diagnostic ZIP")
288+
p_bundle.add_argument("output")
289+
p_bundle.add_argument("--error", help="Failure summary")
290+
p_bundle.add_argument("--context", help="JSON object with run context")
291+
p_bundle.add_argument("--log", help="Log file whose redacted tail is included")
292+
p_bundle.add_argument("--attach", action="append",
293+
help="Explicit attachment; may be repeated")
294+
p_bundle.add_argument("--no-screenshot", action="store_true")
295+
p_bundle.add_argument("--no-diagnostics", action="store_true")
296+
p_bundle.set_defaults(func=cmd_failure_bundle)
297+
261298
p_jobs = sub.add_parser("list-jobs", help="List scheduler jobs")
262299
p_jobs.set_defaults(func=cmd_list_jobs)
263300

je_auto_control/utils/codegen/codegen.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,24 @@ def _body(actions: Sequence, style: str) -> str:
7272
raise ValueError(f"unknown codegen style: {style!r}")
7373

7474

75-
def _render_pytest(actions: Sequence, name: str, style: str) -> str:
76-
body = textwrap.indent(_body(actions, style), " ")
75+
def _render_pytest(actions: Sequence, name: str, style: str,
76+
failure_bundle: bool = False) -> str:
77+
raw_body = _body(actions, style)
78+
if failure_bundle:
79+
body = (" with ac.failure_bundle_on_error(\n"
80+
f" {(_slug(name) + '-failure.zip')!r},\n"
81+
f" context={{'generated_test': {_slug(name)!r}}}):\n"
82+
+ textwrap.indent(raw_body, " "))
83+
else:
84+
body = textwrap.indent(raw_body, " ")
7785
return (f'"""{_HEADER}"""\n'
78-
"import je_auto_control as ac\n\n\n"
79-
f"def test_{_slug(name)}():\n{body}\n")
86+
+ ("import je_auto_control.api as ac\n\n\n" if failure_bundle
87+
else "import je_auto_control as ac\n\n\n")
88+
+ f"def test_{_slug(name)}():\n{body}\n")
8089

8190

82-
def _render_python(actions: Sequence, name: str, style: str) -> str:
91+
def _render_python(actions: Sequence, name: str, style: str,
92+
_failure_bundle: bool = False) -> str:
8393
slug = _slug(name)
8494
body = textwrap.indent(_body(actions, style), " ")
8595
return (f'"""{_HEADER}"""\n'
@@ -89,7 +99,8 @@ def _render_python(actions: Sequence, name: str, style: str) -> str:
8999
f" {slug}()\n")
90100

91101

92-
def _render_robot(actions: Sequence, name: str, _style: str) -> str:
102+
def _render_robot(actions: Sequence, name: str, _style: str,
103+
_failure_bundle: bool = False) -> str:
93104
payload = json.dumps([list(action) for action in actions],
94105
ensure_ascii=False)
95106
test_name = name.replace("_", " ").strip().title() or "Recorded Flow"
@@ -113,22 +124,27 @@ def _render_robot(actions: Sequence, name: str, _style: str) -> str:
113124

114125

115126
def generate_code(actions: Sequence, target: str = "pytest",
116-
name: str = "recorded_flow", style: str = "calls") -> str:
127+
name: str = "recorded_flow", style: str = "calls",
128+
failure_bundle: bool = False) -> str:
117129
"""Render ``actions`` as source code for ``target`` (pytest/python/robot)."""
118130
if not isinstance(actions, list) or not actions:
119131
raise ValueError("actions must be a non-empty list")
120132
renderer = _RENDERERS.get(target)
121133
if renderer is None:
122134
raise ValueError(f"unknown codegen target: {target!r}")
123-
return renderer(actions, name, style)
135+
if failure_bundle and target != "pytest":
136+
raise ValueError("failure_bundle is currently supported for pytest only")
137+
return renderer(actions, name, style, failure_bundle)
124138

125139

126140
def generate_code_file(source, output_path: str, target: str = "pytest",
127-
name: str = "recorded_flow", style: str = "calls") -> str:
141+
name: str = "recorded_flow", style: str = "calls",
142+
failure_bundle: bool = False) -> str:
128143
"""Generate code from a list or JSON action-file path; write and return it."""
129144
actions = source if isinstance(source, list) else read_action_json(
130145
os.path.realpath(source))
131-
code = generate_code(actions, target=target, name=name, style=style)
146+
code = generate_code(actions, target=target, name=name, style=style,
147+
failure_bundle=failure_bundle)
132148
with open(os.path.realpath(output_path), "w", encoding="utf-8") as handle:
133149
handle.write(code)
134150
return code

je_auto_control/utils/config_redaction/config_redaction.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,26 @@ def redact_config(obj: Any, *, mask: str = _DEFAULT_MASK) -> Any:
4444

4545
def redact_secret_text(text: str, *, mask: str = _DEFAULT_MASK) -> str:
4646
"""Mask secret-looking tokens within a free-text string (e.g. a log line)."""
47+
# Explicit credential syntax must be masked even when the value is short or
48+
# low-entropy and therefore intentionally below the generic scanner's
49+
# threshold (common in tests, local deployments, and leaked error text).
50+
text = re.sub(
51+
r"(?i)(\bauthorization\s*:\s*bearer\s+)[^\s,;]+",
52+
lambda match: match.group(1) + mask,
53+
text or "",
54+
)
55+
text = re.sub(
56+
r"(?i)(\b(?:api[_-]?key|access[_-]?token|token|password|passwd|secret)"
57+
r"\s*[=:]\s*)([^\s,;]+)",
58+
lambda match: match.group(1) + mask,
59+
text,
60+
)
61+
4762
def _replace(match: "re.Match[str]") -> str:
4863
token = match.group(0)
4964
core = token.strip(_PUNCT)
5065
if core and scan_secrets({"value": core}):
5166
return token.replace(core, mask)
5267
return token
5368

54-
return re.sub(r"\S+", _replace, text or "")
69+
return re.sub(r"\S+", _replace, text)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Consistent deprecation warnings for public AutoControl APIs."""
2+
from __future__ import annotations
3+
4+
import functools
5+
import warnings
6+
from typing import Callable, TypeVar, cast
7+
8+
F = TypeVar("F", bound=Callable)
9+
10+
11+
class AutoControlDeprecationWarning(FutureWarning):
12+
"""A user-visible warning for an API scheduled for removal."""
13+
14+
15+
def deprecated(*, since: str, removal: str, replacement: str = ""):
16+
"""Mark a callable deprecated with actionable lifecycle metadata."""
17+
def decorate(func: F) -> F:
18+
message = f"{func.__qualname__} is deprecated since {since}"
19+
message += f" and will be removed in {removal}."
20+
if replacement:
21+
message += f" Use {replacement} instead."
22+
23+
@functools.wraps(func)
24+
def wrapped(*args, **kwargs):
25+
warnings.warn(message, AutoControlDeprecationWarning,
26+
stacklevel=2)
27+
return func(*args, **kwargs)
28+
wrapped.__deprecated__ = { # type: ignore[attr-defined]
29+
"since": since, "removal": removal, "replacement": replacement,
30+
}
31+
return cast(F, wrapped)
32+
return decorate
33+
34+
35+
__all__ = ["AutoControlDeprecationWarning", "deprecated"]
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Portable, redacted failure diagnostics."""
2+
3+
from je_auto_control.utils.failure_bundle.bundle import (
4+
FailureBundleOptions,
5+
create_failure_bundle,
6+
failure_bundle_on_error,
7+
)
8+
9+
__all__ = [
10+
"FailureBundleOptions", "create_failure_bundle", "failure_bundle_on_error",
11+
]

0 commit comments

Comments
 (0)