flow2skill 0.1.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.
flow2skill/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Flow2Skill: browser demonstrations compiled into portable skills and tests."""
2
+
3
+ from .model import Action, Selector, Workflow
4
+
5
+ __all__ = ["Action", "Selector", "Workflow"]
6
+ __version__ = "0.1.0"
flow2skill/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
flow2skill/cli.py ADDED
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import shutil
6
+ import sys
7
+ from importlib.metadata import version
8
+ from importlib.resources import files
9
+ from pathlib import Path
10
+
11
+ from . import __version__
12
+ from .exporter import write_bundle
13
+ from .model import FlowValidationError, Workflow
14
+ from .parser import parse_codegen_file
15
+ from .recorder import DEFAULT_CODEGEN_VERSION, compile_source, record_blocking
16
+ from .replay import replay
17
+
18
+ DEFAULT_WORKSPACES = Path.home() / "Flow2SkillWorkspaces"
19
+
20
+
21
+ DEMO_CAPTURE_VALUE = "synthetic-demo-value-7Q9X"
22
+
23
+
24
+ def demo_recording() -> str:
25
+ fixture = Path(str(files("flow2skill").joinpath("ui/demo_form.html"))).resolve().as_uri()
26
+ return f"""from playwright.sync_api import Page, expect
27
+
28
+
29
+ def test_agent_release_gate(page: Page) -> None:
30
+ page.goto({fixture!r})
31
+ page.get_by_label("API token").fill({DEMO_CAPTURE_VALUE!r})
32
+ page.get_by_role("button", name="Validate workflow").click()
33
+ expect(page.get_by_text("Ready for deterministic replay", exact=True)).to_be_visible()
34
+ """
35
+
36
+
37
+ def build_parser() -> argparse.ArgumentParser:
38
+ parser = argparse.ArgumentParser(
39
+ prog="flow2skill",
40
+ description="Demonstrate a browser flow once; compile it into an agent skill and a test.",
41
+ )
42
+ parser.add_argument("--version", action="version", version=f"Flow2Skill {__version__}")
43
+ commands = parser.add_subparsers(dest="command", required=True)
44
+
45
+ record = commands.add_parser(
46
+ "record", help="Open Playwright codegen and capture a browser flow"
47
+ )
48
+ record.add_argument("url")
49
+ record.add_argument("--name", required=True)
50
+ record.add_argument("--intent", default="Replay the demonstrated browser workflow reliably.")
51
+ record.add_argument("--success", default="The recorded assertions pass.")
52
+ record.add_argument("--success-text")
53
+ record.add_argument("--out", type=Path, default=DEFAULT_WORKSPACES)
54
+ record.add_argument("--channel", default="chrome")
55
+ record.add_argument(
56
+ "--protect-inputs",
57
+ action=argparse.BooleanOptionalAction,
58
+ default=True,
59
+ help="Replace typed and asserted values with environment variables (default: enabled)",
60
+ )
61
+
62
+ compile_cmd = commands.add_parser("compile", help="Compile Playwright Python codegen output")
63
+ compile_cmd.add_argument("recording", type=Path)
64
+ compile_cmd.add_argument("--name", required=True)
65
+ compile_cmd.add_argument(
66
+ "--intent", default="Replay the demonstrated browser workflow reliably."
67
+ )
68
+ compile_cmd.add_argument("--success", default="The recorded assertions pass.")
69
+ compile_cmd.add_argument("--success-text")
70
+ compile_cmd.add_argument("--out", type=Path, required=True)
71
+ compile_cmd.add_argument(
72
+ "--protect-inputs",
73
+ action=argparse.BooleanOptionalAction,
74
+ default=True,
75
+ help="Replace typed and asserted values with environment variables (default: enabled)",
76
+ )
77
+
78
+ export = commands.add_parser("export", help="Regenerate artifacts from flow.json")
79
+ export.add_argument("flow", type=Path)
80
+ export.add_argument("--out", type=Path, required=True)
81
+
82
+ inspect = commands.add_parser("inspect", help="Print a safe execution plan")
83
+ inspect.add_argument("flow", type=Path)
84
+
85
+ replay_cmd = commands.add_parser("replay", help="Dry-run or execute a compiled workflow")
86
+ replay_cmd.add_argument("flow", type=Path)
87
+ replay_cmd.add_argument("--live", action="store_true")
88
+ replay_cmd.add_argument("--headed", action="store_true")
89
+ replay_cmd.add_argument("--allow-side-effects", action="store_true")
90
+ replay_cmd.add_argument("--channel")
91
+ replay_cmd.add_argument("--evidence-dir", type=Path)
92
+
93
+ demo = commands.add_parser("demo", help="Generate an executable local sample bundle")
94
+ demo.add_argument("--out", type=Path, default=Path("flow2skill-demo"))
95
+
96
+ commands.add_parser("doctor", help="Check recorder and replay prerequisites")
97
+
98
+ studio = commands.add_parser("studio", help="Launch the local Flow2Skill Studio UI")
99
+ studio.add_argument("--host", default="127.0.0.1")
100
+ studio.add_argument("--port", type=int, default=8765)
101
+ studio.add_argument("--workspace-root", type=Path, default=DEFAULT_WORKSPACES)
102
+ studio.add_argument("--no-open", action="store_true")
103
+ return parser
104
+
105
+
106
+ def doctor() -> int:
107
+ checks: list[tuple[str, bool, str]] = []
108
+ checks.append(("Python", sys.version_info >= (3, 10), sys.version.split()[0]))
109
+ playwright_version = version("playwright")
110
+ checks.append(
111
+ (
112
+ "Python Playwright",
113
+ playwright_version == DEFAULT_CODEGEN_VERSION,
114
+ f"{playwright_version} (expected {DEFAULT_CODEGEN_VERSION})",
115
+ )
116
+ )
117
+ checks.append(("Node.js", shutil.which("node") is not None, shutil.which("node") or "missing"))
118
+ checks.append(("npx", shutil.which("npx") is not None, shutil.which("npx") or "missing"))
119
+ try:
120
+ from playwright.sync_api import sync_playwright
121
+
122
+ with sync_playwright() as playwright:
123
+ browser_path = Path(playwright.chromium.executable_path)
124
+ checks.append(("Managed Chromium", browser_path.is_file(), str(browser_path)))
125
+ except Exception as exc:
126
+ checks.append(("Managed Chromium", False, f"{type(exc).__name__}: {exc}"))
127
+
128
+ for label, passed, detail in checks:
129
+ print(f"[{'PASS' if passed else 'FAIL'}] {label}: {detail}")
130
+ if all(passed for _, passed, _ in checks):
131
+ print("Flow2Skill is ready to record and replay workflows.")
132
+ return 0
133
+ print("Run `python -m playwright install chromium` after fixing missing prerequisites.")
134
+ return 2
135
+
136
+
137
+ def main(argv: list[str] | None = None) -> int:
138
+ args = build_parser().parse_args(argv)
139
+ try:
140
+ if args.command == "record":
141
+ result = record_blocking(
142
+ name=args.name,
143
+ url=args.url,
144
+ output_root=args.out,
145
+ intent=args.intent,
146
+ success_criteria=args.success,
147
+ success_text=args.success_text,
148
+ redact_all_inputs=args.protect_inputs,
149
+ channel=args.channel,
150
+ )
151
+ print(f"Compiled: {result['output_dir']}")
152
+ elif args.command == "compile":
153
+ workflow = parse_codegen_file(
154
+ args.recording,
155
+ name=args.name,
156
+ intent=args.intent,
157
+ success_criteria=args.success,
158
+ success_text=args.success_text,
159
+ redact_all_inputs=args.protect_inputs,
160
+ )
161
+ paths = write_bundle(workflow, args.out)
162
+ print(json.dumps({key: str(value) for key, value in paths.items()}, indent=2))
163
+ elif args.command == "export":
164
+ paths = write_bundle(Workflow.read(args.flow), args.out)
165
+ print(json.dumps({key: str(value) for key, value in paths.items()}, indent=2))
166
+ elif args.command == "inspect":
167
+ print(replay(Workflow.read(args.flow), live=False))
168
+ elif args.command == "replay":
169
+ print(
170
+ replay(
171
+ Workflow.read(args.flow),
172
+ live=args.live,
173
+ headed=args.headed,
174
+ allow_side_effects=args.allow_side_effects,
175
+ channel=args.channel,
176
+ evidence_dir=args.evidence_dir,
177
+ )
178
+ )
179
+ elif args.command == "demo":
180
+ result = compile_source(
181
+ demo_recording(),
182
+ name="Agent release gate",
183
+ output_dir=args.out,
184
+ intent="Validate a protected agent configuration and prove it is ready for replay.",
185
+ success_criteria="The exact deterministic ready state is visible.",
186
+ )
187
+ test_path = result["paths"]["test"]
188
+ print(f"Generated executable demo: {result['output_dir']}")
189
+ print(
190
+ "Verify with: F2S_LABEL_API_TOKEN_1='runtime-demo-token' "
191
+ "FLOW2SKILL_LIVE=1 FLOW2SKILL_ALLOW_SIDE_EFFECTS=1 "
192
+ f"pytest -q {test_path}"
193
+ )
194
+ elif args.command == "doctor":
195
+ return doctor()
196
+ elif args.command == "studio":
197
+ from .server import serve
198
+
199
+ serve(
200
+ host=args.host,
201
+ port=args.port,
202
+ workspace_root=args.workspace_root,
203
+ open_browser=not args.no_open,
204
+ )
205
+ return 0
206
+ except (FlowValidationError, OSError, ValueError) as exc:
207
+ print(f"ERROR: {exc}", file=sys.stderr)
208
+ return 2
209
+
210
+
211
+ if __name__ == "__main__":
212
+ raise SystemExit(main())
flow2skill/exporter.py ADDED
@@ -0,0 +1,359 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .model import Action, FlowValidationError, Selector, Workflow
9
+
10
+
11
+ def selector_expression(selector: Selector) -> str:
12
+ if selector.engine == "page":
13
+ expression = "page"
14
+ elif selector.engine == "role":
15
+ args = [python_value(selector.role or "")]
16
+ if selector.name is not None:
17
+ args.append(f"name={python_value(selector.name)}")
18
+ if selector.exact is not None:
19
+ args.append(f"exact={selector.exact!r}")
20
+ expression = f"page.get_by_role({', '.join(args)})"
21
+ else:
22
+ method = {
23
+ "label": "get_by_label",
24
+ "placeholder": "get_by_placeholder",
25
+ "text": "get_by_text",
26
+ "test_id": "get_by_test_id",
27
+ "title": "get_by_title",
28
+ "alt_text": "get_by_alt_text",
29
+ "css": "locator",
30
+ }.get(selector.engine)
31
+ if not method:
32
+ raise ValueError(f"Unsupported selector engine: {selector.engine}")
33
+ args = [python_value(selector.value or "")]
34
+ if selector.exact is not None and method != "locator":
35
+ args.append(f"exact={selector.exact!r}")
36
+ expression = f"page.{method}({', '.join(args)})"
37
+ for modifier in selector.modifiers:
38
+ if modifier == "first":
39
+ expression += ".first"
40
+ elif modifier.startswith("nth:"):
41
+ expression += f".nth({int(modifier.split(':', 1)[1])})"
42
+ else:
43
+ raise FlowValidationError(f"Unsupported selector modifier: {modifier}")
44
+ return expression
45
+
46
+
47
+ def python_value(value: Any) -> str:
48
+ if isinstance(value, str) and "${" in value:
49
+ return f"resolve_template({value!r})"
50
+ return repr(value)
51
+
52
+
53
+ def action_python(action: Action, *, indent: str = " ") -> list[str]:
54
+ target = selector_expression(action.selector)
55
+ if action.kind == "goto":
56
+ return [f"{indent}page.goto({python_value(action.value)})"]
57
+ if action.kind in {"click", "check", "uncheck", "hover"}:
58
+ return [f"{indent}{target}.{action.kind}()"]
59
+ if action.kind in {"fill", "press", "select_option"}:
60
+ return [f"{indent}{target}.{action.kind}({python_value(action.value)})"]
61
+ if action.kind == "assert_visible":
62
+ return [f"{indent}expect({target}).to_be_visible()"]
63
+ if action.kind == "assert_text":
64
+ return [f"{indent}expect({target}).to_contain_text({python_value(action.expected)})"]
65
+ if action.kind == "assert_exact_text":
66
+ return [f"{indent}expect({target}).to_have_text({python_value(action.expected)})"]
67
+ if action.kind == "assert_url":
68
+ return [f"{indent}expect(page).to_have_url({python_value(action.expected)})"]
69
+ if action.kind == "assert_value":
70
+ return [f"{indent}expect({target}).to_have_value({python_value(action.expected)})"]
71
+ raise ValueError(f"Unsupported action: {action.kind}")
72
+
73
+
74
+ def render_test(workflow: Workflow) -> str:
75
+ workflow.validate()
76
+ risky = [a for a in workflow.actions if a.risk != "safe"]
77
+ body: list[str] = []
78
+ for index, action in enumerate(workflow.actions, start=1):
79
+ summary = describe_action(action).replace("\r", "\\r").replace("\n", "\\n")
80
+ body.append(f" # Step {index}: {summary}")
81
+ body.extend(action_python(action, indent=" "))
82
+ if not any(a.kind.startswith("assert_") for a in workflow.actions):
83
+ body.append(' pytest.fail("No executable assertion was captured")')
84
+
85
+ risk_guard = ""
86
+ if risky:
87
+ labels = ", ".join(describe_action(action) for action in risky)
88
+ message = f"Reviewed or approval-gated actions present: {labels}"
89
+ risk_guard = f"""\n if os.getenv("FLOW2SKILL_ALLOW_SIDE_EFFECTS") != "1":\n pytest.fail({message!r})\n"""
90
+
91
+ module_doc = (
92
+ f"Generated by Flow2Skill from `{workflow.name}`.\n\n"
93
+ "Live browser execution is opt-in. Protected values are resolved only at runtime."
94
+ )
95
+ generated = f"""{module_doc!r}
96
+ from __future__ import annotations
97
+
98
+ import os
99
+ import re
100
+
101
+ import pytest
102
+ from playwright.sync_api import expect, sync_playwright
103
+
104
+
105
+ def require_env(name: str) -> str:
106
+ value = os.getenv(name)
107
+ if value is None:
108
+ pytest.fail(f"Required Flow2Skill variable is missing: {{name}}")
109
+ return value
110
+
111
+
112
+ def resolve_template(value: str) -> str:
113
+ return re.sub(
114
+ r"\\$\\{{(F2S_[A-Z0-9_]+)\\}}",
115
+ lambda match: require_env(match.group(1)),
116
+ value,
117
+ )
118
+
119
+
120
+ @pytest.mark.skipif(
121
+ os.getenv("FLOW2SKILL_LIVE") != "1",
122
+ reason="Set FLOW2SKILL_LIVE=1 to run this recorded browser regression.",
123
+ )
124
+ def test_{workflow.slug.replace("-", "_")}() -> None:{risk_guard}
125
+ with sync_playwright() as playwright:
126
+ launch_options = {{"headless": os.getenv("FLOW2SKILL_HEADED") != "1"}}
127
+ if channel := os.getenv("FLOW2SKILL_CHANNEL"):
128
+ launch_options["channel"] = channel
129
+ browser = playwright.chromium.launch(**launch_options)
130
+ context = browser.new_context()
131
+ page = context.new_page()
132
+ try:
133
+ {chr(10).join(body)}
134
+ finally:
135
+ context.close()
136
+ browser.close()
137
+ """
138
+ try:
139
+ ast.parse(generated)
140
+ except SyntaxError as exc:
141
+ raise FlowValidationError(f"Generated test is invalid Python: {exc.msg}") from exc
142
+ return generated
143
+
144
+
145
+ def describe_action(action: Action) -> str:
146
+ target = action.selector.label()
147
+ if action.kind == "goto":
148
+ return f"navigate to {action.value}"
149
+ if action.kind == "click":
150
+ return f"click {target}"
151
+ if action.kind == "fill":
152
+ rendered = (
153
+ "a protected environment value"
154
+ if str(action.value).startswith("${")
155
+ else repr(action.value)
156
+ )
157
+ return f"fill {target} with {rendered}"
158
+ if action.kind == "press":
159
+ return f"press {action.value!r} in {target}"
160
+ if action.kind == "select_option":
161
+ return f"select {action.value!r} in {target}"
162
+ if action.kind in {"check", "uncheck", "hover"}:
163
+ return f"{action.kind.replace('_', ' ')} {target}"
164
+ if action.kind == "assert_visible":
165
+ return f"verify {target} is visible"
166
+ if action.kind == "assert_text":
167
+ return f"verify {target} contains {action.expected!r}"
168
+ if action.kind == "assert_exact_text":
169
+ return f"verify {target} exactly equals {action.expected!r}"
170
+ if action.kind == "assert_url":
171
+ return f"verify the URL is {action.expected!r}"
172
+ if action.kind == "assert_value":
173
+ return f"verify {target} has value {action.expected!r}"
174
+ raise ValueError(f"Unsupported action: {action.kind}")
175
+
176
+
177
+ def render_skill(workflow: Workflow) -> str:
178
+ workflow.validate()
179
+
180
+ def risk_suffix(action: Action) -> str:
181
+ if action.risk == "approval":
182
+ return " **Approval required before this step. Exact user approval is mandatory.**"
183
+ if action.risk == "review":
184
+ return " **Review this step before replay.**"
185
+ return ""
186
+
187
+ steps = "\n".join(
188
+ f"{index}. {describe_action(action)}" + risk_suffix(action)
189
+ for index, action in enumerate(workflow.actions, start=1)
190
+ )
191
+ variables = (
192
+ "\n".join(
193
+ f"- `{name}`: provide at runtime; never write it into the skill."
194
+ for name in workflow.variables
195
+ )
196
+ if workflow.variables
197
+ else "- None captured."
198
+ )
199
+ warnings = (
200
+ "\n".join(f"- {warning}" for warning in workflow.warnings)
201
+ if workflow.warnings
202
+ else "- None."
203
+ )
204
+ description = json.dumps(workflow.intent, ensure_ascii=False)
205
+ return f"""---
206
+ name: {workflow.slug}
207
+ description: {description}
208
+ version: 1.0.0
209
+ metadata:
210
+ flow2skill:
211
+ fingerprint: {workflow.fingerprint()}
212
+ source: {json.dumps(workflow.source, ensure_ascii=False)}
213
+ ---
214
+
215
+ # {workflow.name}
216
+
217
+ ## Trigger
218
+
219
+ Use this skill when the user wants to: {workflow.intent.rstrip(".")}.
220
+
221
+ ## Safety boundary
222
+
223
+ - Treat this workflow as a demonstrated procedure, not permission to perform external side effects.
224
+ - Posting, publishing, purchasing, sending, submitting, account changes, and destructive actions require exact user approval at the marked step.
225
+ - Run the generated pytest in headless mode first. Use headed mode only when visual inspection is needed.
226
+ - Never place secrets in this file. Supply protected values through environment variables.
227
+
228
+ ## Runtime variables
229
+
230
+ {variables}
231
+
232
+ ## Procedure
233
+
234
+ {steps}
235
+
236
+ ## Success gate
237
+
238
+ {workflow.success_criteria}
239
+
240
+ The procedure is complete only when all recorded assertions pass. A browser action completing without an assertion is execution evidence, not proof of the intended outcome.
241
+
242
+ ## Verification
243
+
244
+ ```bash
245
+ FLOW2SKILL_LIVE=1 pytest -q test_{workflow.slug.replace("-", "_")}.py
246
+ ```
247
+
248
+ Set `FLOW2SKILL_HEADED=1` for a visible browser. Set `FLOW2SKILL_ALLOW_SIDE_EFFECTS=1` only after reviewing every marked step and obtaining exact approval for approval-gated actions.
249
+
250
+ ## Capture warnings
251
+
252
+ {warnings}
253
+ """
254
+
255
+
256
+ def _yaml_scalar(value: Any) -> str:
257
+ if value is None:
258
+ return "null"
259
+ if value is True:
260
+ return "true"
261
+ if value is False:
262
+ return "false"
263
+ if isinstance(value, (int, float)):
264
+ return str(value)
265
+ return json.dumps(str(value), ensure_ascii=False)
266
+
267
+
268
+ def _yaml_lines(value: Any, indent: int = 0) -> list[str]:
269
+ pad = " " * indent
270
+ if isinstance(value, dict):
271
+ lines: list[str] = []
272
+ for key, item in value.items():
273
+ if isinstance(item, (dict, list)):
274
+ lines.append(f"{pad}{key}:")
275
+ lines.extend(_yaml_lines(item, indent + 2))
276
+ else:
277
+ lines.append(f"{pad}{key}: {_yaml_scalar(item)}")
278
+ return lines
279
+ if isinstance(value, list):
280
+ lines = []
281
+ for item in value:
282
+ if isinstance(item, dict):
283
+ first, *rest = _yaml_lines(item, indent + 2)
284
+ lines.append(f"{pad}- {first.strip()}")
285
+ lines.extend(rest)
286
+ elif isinstance(item, list):
287
+ lines.append(f"{pad}-")
288
+ lines.extend(_yaml_lines(item, indent + 2))
289
+ else:
290
+ lines.append(f"{pad}- {_yaml_scalar(item)}")
291
+ return lines
292
+ return [f"{pad}{_yaml_scalar(value)}"]
293
+
294
+
295
+ def render_readme(workflow: Workflow) -> str:
296
+ risky = sum(action.risk == "approval" for action in workflow.actions)
297
+ assertions = sum(action.kind.startswith("assert_") for action in workflow.actions)
298
+ return f"""# {workflow.name}
299
+
300
+ Generated locally by Flow2Skill from a successful human-demonstrated browser workflow.
301
+
302
+ - Steps: {len(workflow.actions)}
303
+ - Assertions: {assertions}
304
+ - Approval-gated steps: {risky}
305
+ - Fingerprint: `{workflow.fingerprint()}`
306
+
307
+ ## Files
308
+
309
+ - `flow.json`: canonical machine-readable workflow
310
+ - `flow.yaml`: human-readable workflow
311
+ - `SKILL.md`: portable agent procedure
312
+ - `test_{workflow.slug.replace("-", "_")}.py`: standalone Playwright/pytest regression
313
+
314
+ ## Verify
315
+
316
+ ```bash
317
+ python -m pip install playwright pytest
318
+ FLOW2SKILL_LIVE=1 pytest -q test_{workflow.slug.replace("-", "_")}.py
319
+ ```
320
+
321
+ Use `FLOW2SKILL_HEADED=1` to watch the replay. Protected input variables are listed in `SKILL.md` and must be supplied through the environment.
322
+ """
323
+
324
+
325
+ def write_bundle(workflow: Workflow, output_dir: str | Path) -> dict[str, Path]:
326
+ workflow.validate()
327
+ root = Path(output_dir).resolve()
328
+ root.mkdir(parents=True, exist_ok=True)
329
+ previous_test: Path | None = None
330
+ manifest_path = root / "flow.json"
331
+ if manifest_path.is_file():
332
+ try:
333
+ previous_workflow = Workflow.read(manifest_path)
334
+ except (OSError, ValueError):
335
+ pass
336
+ else:
337
+ previous_test = root / f"test_{previous_workflow.slug.replace('-', '_')}.py"
338
+ payload = workflow.to_dict()
339
+ paths = {
340
+ "flow_json": root / "flow.json",
341
+ "flow_yaml": root / "flow.yaml",
342
+ "skill": root / "SKILL.md",
343
+ "test": root / f"test_{workflow.slug.replace('-', '_')}.py",
344
+ "readme": root / "README.md",
345
+ }
346
+ if (
347
+ previous_test is not None
348
+ and previous_test != paths["test"]
349
+ and (previous_test.is_file() or previous_test.is_symlink())
350
+ ):
351
+ previous_test.unlink()
352
+ paths["flow_json"].write_text(
353
+ json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
354
+ )
355
+ paths["flow_yaml"].write_text("\n".join(_yaml_lines(payload)) + "\n", encoding="utf-8")
356
+ paths["skill"].write_text(render_skill(workflow), encoding="utf-8")
357
+ paths["test"].write_text(render_test(workflow), encoding="utf-8")
358
+ paths["readme"].write_text(render_readme(workflow), encoding="utf-8")
359
+ return paths