gflow-cli 0.2.0a1__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.
flow_cli/auth.py ADDED
@@ -0,0 +1,87 @@
1
+ """Auth — capture/refresh Google session via Playwright persistent context.
2
+
3
+ Sessions live under `Settings.home / profile_<name>/`. Default location is
4
+ the OS-native user-data-dir (via `platformdirs`):
5
+
6
+ * Windows: `%LOCALAPPDATA%\\flow-cli\\profile_<name>`
7
+ * macOS: `~/Library/Application Support/flow-cli/profile_<name>`
8
+ * Linux: `~/.local/share/flow-cli/profile_<name>` (XDG)
9
+
10
+ Override the root with `FLOW_CLI_HOME`. See `docs/AUTHENTICATION.md`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from pathlib import Path
17
+
18
+ from playwright.async_api import async_playwright
19
+
20
+ from flow_cli.config import get_settings
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ GEMINI_URL = "https://labs.google/fx/tools/flow?hl=en"
25
+
26
+
27
+ def default_profile_root() -> Path:
28
+ """Root dir under which `profile_<name>/` subdirectories live.
29
+
30
+ Returns `Settings.home`. Reads env via `get_settings()` so changes to
31
+ `FLOW_CLI_HOME` after import are honoured (provided the cache is reset).
32
+ """
33
+ return get_settings().home
34
+
35
+
36
+ def profile_dir(name: str = "default") -> Path:
37
+ return get_settings().profile_subdir(name)
38
+
39
+
40
+ async def login(name: str = "default") -> Path:
41
+ """Open a HEADED Chromium window, let user sign into Google, persist session.
42
+
43
+ Returns the profile directory path. On subsequent runs the saved cookies
44
+ are reused; if Google's session expires, calling this again re-captures it.
45
+ """
46
+ pdir = profile_dir(name)
47
+ pdir.mkdir(parents=True, exist_ok=True)
48
+ logger.info("login: launching browser, profile=%s", pdir)
49
+ async with async_playwright() as pw:
50
+ ctx = await pw.chromium.launch_persistent_context(
51
+ user_data_dir=str(pdir),
52
+ headless=False,
53
+ viewport={"width": 1280, "height": 800},
54
+ )
55
+ try:
56
+ page = ctx.pages[0] if ctx.pages else await ctx.new_page()
57
+ await page.goto(GEMINI_URL, wait_until="domcontentloaded", timeout=60_000)
58
+ print(
59
+ "\n Sign into your Google account in the open window.\n"
60
+ " Once you reach the Flow editor, close the window to save the session.\n"
61
+ )
62
+ try:
63
+ await ctx.wait_for_event("close", timeout=600_000) # pyright: ignore[reportUnknownMemberType]
64
+ except Exception:
65
+ pass
66
+ finally:
67
+ try:
68
+ await ctx.close()
69
+ except Exception:
70
+ pass
71
+ return pdir
72
+
73
+
74
+ def status(name: str = "default") -> dict[str, object]:
75
+ """Lightweight check — does the profile dir exist and have cookies file?"""
76
+ pdir = profile_dir(name)
77
+ cookies_file: Path | None = None
78
+ for candidate in (pdir / "Default" / "Cookies", pdir / "Cookies"):
79
+ if candidate.exists():
80
+ cookies_file = candidate
81
+ break
82
+ return {
83
+ "profile": str(pdir),
84
+ "exists": pdir.exists(),
85
+ "cookies_present": cookies_file is not None,
86
+ "cookies_path": str(cookies_file) if cookies_file else None,
87
+ }
flow_cli/cli.py ADDED
@@ -0,0 +1,184 @@
1
+ """CLI entry point — Click app exposing the gflow commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import sys
8
+
9
+ import click
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from flow_cli import __version__, profile_store
14
+ from flow_cli import auth as auth_mod
15
+ from flow_cli.cli_video import video as _video_group
16
+
17
+ console = Console()
18
+
19
+
20
+ def _render_profiles_table(profiles: list[profile_store.ProfileMeta]) -> None:
21
+ """Pretty-print the profile inventory."""
22
+ if not profiles:
23
+ console.print("[yellow]No profiles found.[/yellow]")
24
+ return
25
+ root = auth_mod.default_profile_root()
26
+ console.print(f"\n[bold]Profiles in[/bold] {root}\n")
27
+ table = Table(show_header=True, header_style="bold cyan")
28
+ table.add_column("Default", justify="center")
29
+ table.add_column("Name", style="bold")
30
+ table.add_column("Session")
31
+ table.add_column("Last used (UTC)")
32
+ table.add_column("Profile dir", overflow="fold")
33
+ for p in profiles:
34
+ marker = "[bold green]●[/bold green]" if p.is_default else ""
35
+ session = "[green]present[/green]" if p.cookies_present else "[red]missing[/red]"
36
+ last = p.last_used_at.strftime("%Y-%m-%d %H:%M:%S") if p.last_used_at else "-"
37
+ table.add_row(marker, p.name, session, last, str(p.profile_dir))
38
+ console.print(table)
39
+ console.print("\nUse [bold]gflow auth use <name>[/bold] to set the default profile.")
40
+ console.print(
41
+ "Use [bold]gflow auth login --profile <name>[/bold] to add or refresh a profile.\n"
42
+ )
43
+
44
+
45
+ @click.group()
46
+ @click.version_option(__version__, "-V", "--version")
47
+ @click.option("--verbose", "-v", is_flag=True, help="Verbose logging.")
48
+ @click.pass_context
49
+ def main(ctx: click.Context, verbose: bool) -> None:
50
+ """gflow — drive Google Flow Veo I2V from the terminal."""
51
+ logging.basicConfig(
52
+ level=logging.DEBUG if verbose else logging.INFO,
53
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
54
+ )
55
+ ctx.ensure_object(dict)
56
+
57
+
58
+ # --- auth -------------------------------------------------------------------
59
+
60
+
61
+ @main.group(invoke_without_command=True)
62
+ @click.pass_context
63
+ def auth(ctx: click.Context) -> None:
64
+ """Manage Google sessions for Flow.
65
+
66
+ Bare `gflow auth` shows the profile inventory. If no profiles exist yet,
67
+ it kicks off `gflow auth login` automatically.
68
+ """
69
+ if ctx.invoked_subcommand is not None:
70
+ return
71
+ profiles = profile_store.list_profiles()
72
+ if not profiles:
73
+ console.print("[yellow]No profiles found.[/yellow] Launching first-time login...\n")
74
+ ctx.invoke(auth_login)
75
+ return
76
+ _render_profiles_table(profiles)
77
+
78
+
79
+ @auth.command("login")
80
+ @click.option(
81
+ "--profile",
82
+ default=None,
83
+ help="Profile name. Defaults to the resolved default (env > config > auto).",
84
+ )
85
+ def auth_login(profile: str | None) -> None:
86
+ """One-time interactive sign-in. Opens a browser window."""
87
+ name = profile or _resolve_or_prompt(default_for_first_run="default")
88
+ pdir = asyncio.run(auth_mod.login(name))
89
+ console.print(f"[green]Session saved.[/green] Profile dir: {pdir}")
90
+ # If this was the very first profile, set it as default automatically so
91
+ # subsequent commands work without explicit --profile / FLOW_CLI_PROFILE.
92
+ profiles = profile_store.list_profiles()
93
+ if len(profiles) == 1:
94
+ profile_store.set_default_profile(profiles[0].name)
95
+ console.print(f"[dim]Set [bold]{profiles[0].name}[/bold] as default profile.[/dim]")
96
+
97
+
98
+ @auth.command("status")
99
+ @click.option("--profile", default=None)
100
+ def auth_status(profile: str | None) -> None:
101
+ """Show whether a specific profile has a saved session."""
102
+ name = profile or _resolve_or_exit()
103
+ s = auth_mod.status(name)
104
+ if s["exists"] and s["cookies_present"]:
105
+ console.print(f"[green]Profile '{name}' is configured.[/green]")
106
+ else:
107
+ console.print(
108
+ f"[yellow]Profile '{name}' has no session.[/yellow] "
109
+ f"Run [bold]gflow auth login --profile {name}[/bold]."
110
+ )
111
+ for k, v in s.items():
112
+ console.print(f" {k}: {v}")
113
+
114
+
115
+ @auth.command("list")
116
+ def auth_list() -> None:
117
+ """List every profile and indicate the current default."""
118
+ profiles = profile_store.list_profiles()
119
+ _render_profiles_table(profiles)
120
+
121
+
122
+ @auth.command("use")
123
+ @click.argument("name")
124
+ def auth_use(name: str) -> None:
125
+ """Set NAME as the default profile."""
126
+ try:
127
+ cfg = profile_store.set_default_profile(name)
128
+ except FileNotFoundError as e:
129
+ console.print(f"[red]{e}[/red]")
130
+ sys.exit(2)
131
+ console.print(
132
+ f"[green]Default profile set to[/green] [bold]{name}[/bold]\n[dim]Persisted in {cfg}[/dim]"
133
+ )
134
+
135
+
136
+ @auth.command("logout")
137
+ @click.option("--profile", default=None)
138
+ @click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt.")
139
+ def auth_logout(profile: str | None, yes: bool) -> None:
140
+ """Delete a profile's saved session (irreversible)."""
141
+ name = profile or _resolve_or_exit()
142
+ if not yes:
143
+ click.confirm(
144
+ f"Delete profile '{name}' and all cookies/state?",
145
+ abort=True,
146
+ )
147
+ try:
148
+ deleted = profile_store.delete_profile(name)
149
+ except FileNotFoundError as e:
150
+ console.print(f"[red]{e}[/red]")
151
+ sys.exit(2)
152
+ console.print(f"[yellow]Profile '{name}' removed.[/yellow]\n[dim]Deleted dir: {deleted}[/dim]")
153
+
154
+
155
+ def _resolve_or_exit() -> str:
156
+ """Resolve the active profile or print a friendly error and exit."""
157
+ try:
158
+ return profile_store.resolve_profile(None)
159
+ except profile_store.NoProfilesError as e:
160
+ console.print(f"[yellow]{e}[/yellow]")
161
+ sys.exit(2)
162
+ except profile_store.NoDefaultProfileError as e:
163
+ console.print(f"[yellow]{e}[/yellow]")
164
+ sys.exit(2)
165
+
166
+
167
+ def _resolve_or_prompt(default_for_first_run: str) -> str:
168
+ """Like _resolve_or_exit but for `auth login` — accept any name to create."""
169
+ try:
170
+ return profile_store.resolve_profile(None)
171
+ except profile_store.NoProfilesError:
172
+ return default_for_first_run
173
+ except profile_store.NoDefaultProfileError:
174
+ return click.prompt(
175
+ "Multiple profiles exist; pick a name to login or refresh",
176
+ default=default_for_first_run,
177
+ )
178
+
179
+
180
+ main.add_command(_video_group)
181
+
182
+
183
+ if __name__ == "__main__":
184
+ main()
flow_cli/cli_video.py ADDED
@@ -0,0 +1,322 @@
1
+ """`gflow video` command group — t2v subcommand (text-to-video).
2
+
3
+ Helper functions `_resolve_profile` and `_make_provider_dir` are thin wrappers
4
+ over the same profile/auth machinery used by the rest of cli.py, kept as
5
+ named module-level functions so the test suite can patch them cleanly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ import click
15
+ from rich.console import Console
16
+
17
+ from flow_cli import auth as auth_mod
18
+ from flow_cli import profile_store
19
+ from flow_cli.api.client import FlowApiClient
20
+ from flow_cli.api.video import Aspect, GenerateVideoRequest
21
+ from flow_cli.config import get_settings
22
+ from flow_cli.manifest import ManifestEntry, parse_manifest
23
+ from flow_cli.paths import video_output_path
24
+
25
+ console = Console()
26
+
27
+ # Terminal statuses that end the polling loop.
28
+ _TERMINAL = frozenset(
29
+ [
30
+ "MEDIA_GENERATION_STATUS_COMPLETED",
31
+ "MEDIA_GENERATION_STATUS_FAILED",
32
+ ]
33
+ )
34
+
35
+
36
+ def _resolve_profile(profile: str | None) -> str:
37
+ """Return the active profile name or exit with a friendly message."""
38
+ if profile:
39
+ return profile
40
+ try:
41
+ return profile_store.resolve_profile(None)
42
+ except profile_store.NoProfilesError as exc:
43
+ console.print(f"[yellow]{exc}[/yellow]")
44
+ sys.exit(2)
45
+ except profile_store.NoDefaultProfileError as exc:
46
+ console.print(f"[yellow]{exc}[/yellow]")
47
+ sys.exit(2)
48
+
49
+
50
+ def _make_provider_dir(profile_name: str) -> Path:
51
+ """Return the Playwright profile dir for *profile_name*, or exit if absent."""
52
+ pdir = auth_mod.profile_dir(profile_name)
53
+ if not pdir.exists():
54
+ console.print(
55
+ f"[red]No session for profile '{profile_name}'.[/red] "
56
+ "Run [bold]gflow auth login[/bold] first."
57
+ )
58
+ sys.exit(2)
59
+ return pdir
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Click group
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ @click.group()
68
+ def video() -> None:
69
+ """Generate and manage videos via Google Flow Veo."""
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # t2v subcommand
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ @video.command("t2v")
78
+ @click.argument("prompt")
79
+ @click.option(
80
+ "-o",
81
+ "--output",
82
+ "output",
83
+ default=None,
84
+ type=click.Path(path_type=Path),
85
+ help="Where to save the mp4. Defaults to <output_dir>/videos/<date>/<media>.mp4.",
86
+ )
87
+ @click.option(
88
+ "--aspect",
89
+ default="9:16",
90
+ show_default=True,
91
+ type=click.Choice(["9:16", "16:9", "1:1"]),
92
+ help="Video aspect ratio.",
93
+ )
94
+ @click.option("--seed", default=None, type=int, help="RNG seed for reproducibility.")
95
+ @click.option("--profile", default=None, help="Profile name (overrides default).")
96
+ @click.option(
97
+ "--poll-interval",
98
+ default=5.0,
99
+ show_default=True,
100
+ type=float,
101
+ help="Seconds between status polls.",
102
+ )
103
+ def t2v(
104
+ prompt: str,
105
+ output: Path | None,
106
+ aspect: str,
107
+ seed: int | None,
108
+ profile: str | None,
109
+ poll_interval: float,
110
+ ) -> None:
111
+ """Generate a video from PROMPT (text-to-video)."""
112
+ profile_name = _resolve_profile(profile)
113
+ provider_dir = _make_provider_dir(profile_name)
114
+ settings = get_settings()
115
+ asyncio.run(
116
+ _run_t2v(
117
+ profile_dir=provider_dir,
118
+ headless=settings.headless,
119
+ prompt=prompt,
120
+ output=output,
121
+ aspect=Aspect.from_cli(aspect),
122
+ seed=seed,
123
+ poll_interval=poll_interval,
124
+ output_root=settings.output_dir,
125
+ )
126
+ )
127
+
128
+
129
+ async def _run_t2v(
130
+ *,
131
+ profile_dir: Path,
132
+ headless: bool,
133
+ prompt: str,
134
+ output: Path | None,
135
+ aspect: Aspect,
136
+ seed: int | None,
137
+ poll_interval: float,
138
+ output_root: Path,
139
+ ) -> None:
140
+ async with FlowApiClient(profile_dir=profile_dir, headless=headless) as client:
141
+ console.print(" Creating project...")
142
+ project = await client.create_project()
143
+ console.print(f" Project: {project.project_id}")
144
+ req = GenerateVideoRequest(prompt=prompt, aspect=aspect)
145
+ console.print(" Submitting generation...")
146
+ op = await client.generate_video(project_id=project.project_id, req=req, seed=seed)
147
+ console.print(f" Operation: {op.operation_name}")
148
+ await _poll_and_download(
149
+ client=client,
150
+ project_id=project.project_id,
151
+ media_name=op.media_name,
152
+ output=output or video_output_path(output_root, job_id=op.media_name),
153
+ poll_interval=poll_interval,
154
+ )
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Shared polling helper
159
+ # ---------------------------------------------------------------------------
160
+
161
+
162
+ async def _poll_and_download(
163
+ *,
164
+ client: FlowApiClient,
165
+ project_id: str,
166
+ media_name: str,
167
+ output: Path,
168
+ poll_interval: float,
169
+ ) -> None:
170
+ """Poll until terminal status, then download the result to *output*."""
171
+ while True:
172
+ statuses = await client.get_video_status(project_id, [media_name])
173
+ if not statuses:
174
+ console.print("[red]No status returned from API.[/red]")
175
+ sys.exit(1)
176
+ st = statuses[0]
177
+ console.print(f" Status: {st.status}")
178
+ if st.status in _TERMINAL:
179
+ break
180
+ await asyncio.sleep(poll_interval)
181
+
182
+ if not st.succeeded:
183
+ console.print(f"[red]Generation failed (status={st.status}).[/red]")
184
+ sys.exit(1)
185
+
186
+ saved = await client.download(media_name, output)
187
+ console.print(f"[green]Saved[/green] {saved}")
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # i2v subcommand
192
+ # ---------------------------------------------------------------------------
193
+
194
+
195
+ @video.command("i2v")
196
+ @click.argument("image", type=click.Path(exists=True, dir_okay=False, path_type=Path))
197
+ @click.argument("prompt")
198
+ @click.option("-o", "--output", type=click.Path(path_type=Path), default=None)
199
+ @click.option("--aspect", type=click.Choice(["9:16", "16:9", "1:1"]), default="9:16")
200
+ @click.option("--seed", type=int, default=None)
201
+ @click.option("--profile", default=None)
202
+ @click.option("--poll-interval", type=float, default=5.0)
203
+ def i2v(
204
+ image: Path,
205
+ prompt: str,
206
+ output: Path | None,
207
+ aspect: str,
208
+ seed: int | None,
209
+ profile: str | None,
210
+ poll_interval: float,
211
+ ) -> None:
212
+ """Generate a video from a START IMAGE + text prompt."""
213
+ profile_name = _resolve_profile(profile)
214
+ pdir = _make_provider_dir(profile_name)
215
+ settings = get_settings()
216
+ asyncio.run(
217
+ _run_i2v(
218
+ profile_dir=pdir,
219
+ headless=settings.headless,
220
+ image=image,
221
+ prompt=prompt,
222
+ output=output,
223
+ aspect=Aspect.from_cli(aspect),
224
+ seed=seed,
225
+ poll_interval=poll_interval,
226
+ output_root=settings.output_dir,
227
+ )
228
+ )
229
+
230
+
231
+ async def _run_i2v(
232
+ *,
233
+ profile_dir: Path,
234
+ headless: bool,
235
+ image: Path,
236
+ prompt: str,
237
+ output: Path | None,
238
+ aspect: Aspect,
239
+ seed: int | None,
240
+ poll_interval: float,
241
+ output_root: Path,
242
+ ) -> None:
243
+ async with FlowApiClient(profile_dir=profile_dir, headless=headless) as client:
244
+ console.print(" Creating project...")
245
+ project = await client.create_project()
246
+ console.print(f" Uploading {image.name}...")
247
+ asset = await client.upload_image(project.project_id, image)
248
+ console.print(f" Asset: [dim]{asset.name}[/dim]")
249
+ req = GenerateVideoRequest(prompt=prompt, aspect=aspect, start_asset_uuid=asset.name)
250
+ console.print(" Submitting generation...")
251
+ op = await client.generate_video(project_id=project.project_id, req=req, seed=seed)
252
+ await _poll_and_download(
253
+ client=client,
254
+ project_id=project.project_id,
255
+ media_name=op.media_name,
256
+ output=output or video_output_path(output_root, job_id=op.media_name),
257
+ poll_interval=poll_interval,
258
+ )
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # batch subcommand
263
+ # ---------------------------------------------------------------------------
264
+
265
+
266
+ @video.command("batch")
267
+ @click.argument("manifest", type=click.Path(exists=True, dir_okay=False, path_type=Path))
268
+ @click.option("--out-dir", type=click.Path(path_type=Path), default=None)
269
+ @click.option("--profile", default=None)
270
+ @click.option("--poll-interval", type=float, default=5.0, show_default=True)
271
+ def batch(
272
+ manifest: Path,
273
+ out_dir: Path | None,
274
+ profile: str | None,
275
+ poll_interval: float,
276
+ ) -> None:
277
+ """Run a TSV manifest of video generations."""
278
+ profile_name = _resolve_profile(profile)
279
+ pdir = _make_provider_dir(profile_name)
280
+ settings = get_settings()
281
+ entries = parse_manifest(manifest)
282
+ out_root = out_dir or settings.output_dir
283
+ asyncio.run(
284
+ _run_batch(
285
+ profile_dir=pdir,
286
+ headless=settings.headless,
287
+ entries=entries,
288
+ out_root=out_root,
289
+ poll_interval=poll_interval,
290
+ )
291
+ )
292
+
293
+
294
+ async def _run_batch(
295
+ *,
296
+ profile_dir: Path,
297
+ headless: bool,
298
+ entries: list[ManifestEntry],
299
+ out_root: Path,
300
+ poll_interval: float,
301
+ ) -> None:
302
+ async with FlowApiClient(profile_dir=profile_dir, headless=headless) as client:
303
+ console.print(f" Creating project for {len(entries)} clips...")
304
+ project = await client.create_project()
305
+ for i, e in enumerate(entries, start=1):
306
+ console.print(f" [{i}/{len(entries)}] [bold]{e.prompt[:60]}[/bold]")
307
+ start_uuid = None
308
+ if e.start_image:
309
+ asset = await client.upload_image(project.project_id, e.start_image)
310
+ start_uuid = asset.name
311
+ req = GenerateVideoRequest(
312
+ prompt=e.prompt, aspect=e.aspect, start_asset_uuid=start_uuid
313
+ )
314
+ op = await client.generate_video(project_id=project.project_id, req=req)
315
+ output = e.output_path or video_output_path(out_root, job_id=op.media_name)
316
+ await _poll_and_download(
317
+ client=client,
318
+ project_id=project.project_id,
319
+ media_name=op.media_name,
320
+ output=output,
321
+ poll_interval=poll_interval,
322
+ )