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/config.py ADDED
@@ -0,0 +1,116 @@
1
+ """Process-wide configuration via `pydantic-settings`.
2
+
3
+ All knobs are env-var-driven (prefix `FLOW_CLI_`), with a `.env` fallback
4
+ loaded from CWD or `$FLOW_CLI_HOME/.env`. Validated at startup; bad values
5
+ fail loudly with the offending key + the rule it violated.
6
+
7
+ Resolution precedence (highest first):
8
+ 1. CLI flag (passed at call site, not here)
9
+ 2. Environment variable
10
+ 3. `.env` file (CWD wins over $FLOW_CLI_HOME/.env)
11
+ 4. Built-in default (from `flow_cli.paths`)
12
+
13
+ Use `get_settings()` to access the cached singleton. Tests should call
14
+ `reset_settings()` between cases.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from enum import StrEnum
20
+ from functools import lru_cache
21
+ from pathlib import Path
22
+
23
+ from pydantic import Field
24
+ from pydantic_settings import BaseSettings, SettingsConfigDict
25
+
26
+ from flow_cli import paths
27
+
28
+
29
+ class LogLevel(StrEnum):
30
+ DEBUG = "DEBUG"
31
+ INFO = "INFO"
32
+ WARNING = "WARNING"
33
+ ERROR = "ERROR"
34
+
35
+
36
+ class LogFormat(StrEnum):
37
+ AUTO = "auto"
38
+ TEXT = "text"
39
+ JSON = "json"
40
+
41
+
42
+ class Provider(StrEnum):
43
+ FLOW = "flow"
44
+ OFFICIAL = "official" # planned v0.3+ via googleapis/python-genai
45
+
46
+
47
+ class Settings(BaseSettings):
48
+ """All flow-cli configuration. Build via `Settings()` (or `get_settings()`)."""
49
+
50
+ model_config = SettingsConfigDict(
51
+ env_prefix="FLOW_CLI_",
52
+ env_file=(".env",),
53
+ env_file_encoding="utf-8",
54
+ case_sensitive=False,
55
+ extra="ignore",
56
+ )
57
+
58
+ # --- paths ------------------------------------------------------------
59
+ home: Path = Field(
60
+ default_factory=paths.default_home,
61
+ description="Root for profiles, config.toml, etc.",
62
+ )
63
+ output_dir: Path = Field(
64
+ default_factory=paths.default_output_dir,
65
+ description="Where generated assets land.",
66
+ )
67
+
68
+ # --- profile ----------------------------------------------------------
69
+ profile: str | None = Field(
70
+ default=None,
71
+ description=(
72
+ "Default profile name. None = resolve from config.toml or "
73
+ "auto-pick the only profile present."
74
+ ),
75
+ )
76
+
77
+ # --- provider ---------------------------------------------------------
78
+ provider: Provider = Provider.FLOW
79
+ gemini_api_key: str | None = Field(
80
+ default=None,
81
+ description="Required when provider=official (v0.3+).",
82
+ )
83
+
84
+ # --- runtime ----------------------------------------------------------
85
+ timeout_seconds: int = Field(default=600, ge=1, le=3600)
86
+ concurrency: int = Field(default=1, ge=1, le=16)
87
+ headless: bool = Field(
88
+ default=True,
89
+ description=(
90
+ "Run the Playwright Chromium headless. Set to False if reCAPTCHA "
91
+ "fails to mint tokens (Google sometimes detects headless)."
92
+ ),
93
+ )
94
+
95
+ # --- logging ----------------------------------------------------------
96
+ log_level: LogLevel = LogLevel.INFO
97
+ log_format: LogFormat = LogFormat.AUTO
98
+
99
+ # --- derived path helpers --------------------------------------------
100
+
101
+ def profile_subdir(self, name: str) -> Path:
102
+ return paths.profile_subdir(self.home, name)
103
+
104
+ def config_file(self) -> Path:
105
+ return paths.config_file(self.home)
106
+
107
+
108
+ @lru_cache(maxsize=1)
109
+ def get_settings() -> Settings:
110
+ """Cached settings singleton. Tests should call `reset_settings()`."""
111
+ return Settings()
112
+
113
+
114
+ def reset_settings() -> None:
115
+ """Clear the cache. Call between tests that munge env vars."""
116
+ get_settings.cache_clear()
flow_cli/manifest.py ADDED
@@ -0,0 +1,58 @@
1
+ """TSV manifest parser for `gflow video batch`.
2
+
3
+ Format (tab-separated, optional ``# ``-prefixed comments + blank lines)::
4
+
5
+ start_image\tprompt\tend_image\taspect\toutput_path
6
+
7
+ Columns:
8
+
9
+ - start_image: path to PNG/JPG (empty -> T2V)
10
+ - prompt: required, non-empty
11
+ - end_image: optional second-frame for transition I2V (not yet wired in MVP)
12
+ - aspect: 9:16 | 16:9 | 1:1 (default 9:16)
13
+ - output_path: optional output file path; empty -> default scheme
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from flow_cli.api.video import Aspect
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ManifestEntry:
26
+ prompt: str
27
+ start_image: Path | None = None
28
+ end_image: Path | None = None
29
+ aspect: Aspect = Aspect.PORTRAIT
30
+ output_path: Path | None = None
31
+
32
+
33
+ def parse_manifest(path: Path) -> list[ManifestEntry]:
34
+ """Parse a TSV manifest file. Raises ValueError on bad rows."""
35
+ entries: list[ManifestEntry] = []
36
+ text = path.read_text(encoding="utf-8")
37
+ for lineno, raw in enumerate(text.splitlines(), start=1):
38
+ stripped = raw.strip()
39
+ if not stripped or stripped.startswith("#"):
40
+ continue
41
+ cols = raw.split("\t")
42
+ # Pad to 5 columns so unpacking is always safe.
43
+ while len(cols) < 5:
44
+ cols.append("")
45
+ start_image_s, prompt, end_image_s, aspect_s, output_s = (c.strip() for c in cols[:5])
46
+ if not prompt:
47
+ raise ValueError(f"line {lineno}: prompt is required (got empty)")
48
+ aspect = Aspect.from_cli(aspect_s) if aspect_s else Aspect.PORTRAIT
49
+ entries.append(
50
+ ManifestEntry(
51
+ prompt=prompt,
52
+ start_image=Path(start_image_s) if start_image_s else None,
53
+ end_image=Path(end_image_s) if end_image_s else None,
54
+ aspect=aspect,
55
+ output_path=Path(output_s) if output_s else None,
56
+ )
57
+ )
58
+ return entries
flow_cli/paths.py ADDED
@@ -0,0 +1,82 @@
1
+ """XDG-aware default paths for flow-cli, via `platformdirs`.
2
+
3
+ Single source of truth for where things live on disk:
4
+
5
+ * **profiles + config** under `<user_data_dir>/flow-cli/`
6
+ - Windows: `%LOCALAPPDATA%\\flow-cli\\`
7
+ - macOS: `~/Library/Application Support/flow-cli/`
8
+ - Linux: `$XDG_DATA_HOME/flow-cli/` (typically `~/.local/share/flow-cli/`)
9
+
10
+ * **downloads** under `<user_downloads_dir>/flow-cli/`
11
+ - Windows: `%USERPROFILE%\\Downloads\\flow-cli\\`
12
+ - macOS: `~/Downloads/flow-cli/`
13
+ - Linux: `$XDG_DOWNLOAD_DIR/flow-cli/` (typically `~/Downloads/flow-cli/`)
14
+
15
+ These are the defaults — overridable per-process via env vars
16
+ `FLOW_CLI_HOME` and `FLOW_CLI_OUTPUT_DIR`. Resolution lives in
17
+ `flow_cli.config.Settings`.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from datetime import date
24
+ from pathlib import Path
25
+
26
+ from platformdirs import user_data_dir, user_downloads_dir
27
+
28
+ # Allow only alphanumerics, hyphens, and underscores up to 128 chars.
29
+ # Prevents path traversal via API-returned job IDs.
30
+ _SAFE_ID_RE = re.compile(r"^[\w\-]{1,128}$")
31
+
32
+ APP_NAME = "flow-cli"
33
+ APP_AUTHOR = "ffroliva" # Windows-only; Linux/macOS ignore this.
34
+
35
+
36
+ def default_home() -> Path:
37
+ """Default `FLOW_CLI_HOME` — profiles + config.toml live here."""
38
+ return Path(user_data_dir(APP_NAME, APP_AUTHOR, ensure_exists=False))
39
+
40
+
41
+ def default_output_dir() -> Path:
42
+ """Default `FLOW_CLI_OUTPUT_DIR` — generated assets land here."""
43
+ return Path(user_downloads_dir()) / APP_NAME
44
+
45
+
46
+ def profile_subdir(home: Path, name: str) -> Path:
47
+ """Where profile <name> lives under <home>."""
48
+ return home / f"profile_{name}"
49
+
50
+
51
+ def config_file(home: Path) -> Path:
52
+ """Where the per-user config TOML lives."""
53
+ return home / "config.toml"
54
+
55
+
56
+ def _validate_job_id(job_id: str) -> str:
57
+ if not _SAFE_ID_RE.match(job_id):
58
+ raise ValueError(f"Unsafe job_id returned by API: {job_id!r}")
59
+ return job_id
60
+
61
+
62
+ def video_output_path(
63
+ output_dir: Path,
64
+ *,
65
+ job_id: str,
66
+ on: date | None = None,
67
+ ) -> Path:
68
+ """`<output_dir>/videos/<YYYY-MM-DD>/<job_id>.mp4`."""
69
+ on = on or date.today()
70
+ return output_dir / "videos" / on.isoformat() / f"{_validate_job_id(job_id)}.mp4"
71
+
72
+
73
+ def image_output_path(
74
+ output_dir: Path,
75
+ *,
76
+ job_id: str,
77
+ index: int = 1,
78
+ on: date | None = None,
79
+ ) -> Path:
80
+ """`<output_dir>/images/<YYYY-MM-DD>/<job_id>_<index>.png`."""
81
+ on = on or date.today()
82
+ return output_dir / "images" / on.isoformat() / f"{_validate_job_id(job_id)}_{index}.png"
@@ -0,0 +1,226 @@
1
+ """Profile inventory + default-profile persistence.
2
+
3
+ Single source of truth for: which Google sessions exist, which one to use
4
+ when no `--profile` flag is given, and how to set/clear that default.
5
+
6
+ Storage layout under $FLOW_CLI_HOME (default: see flow_cli.auth.default_profile_root):
7
+ ./profile_<name>/ ← Chromium persistent context per profile
8
+ ./config.toml ← `default_profile = "<name>"`
9
+
10
+ Resolution precedence (highest first):
11
+ 1. Explicit CLI --profile flag
12
+ 2. FLOW_CLI_PROFILE env var
13
+ 3. config.toml's default_profile
14
+ 4. Auto-select if exactly one profile exists
15
+ 5. Raise NoDefaultProfileError with the list of available profiles
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import shutil
22
+ import tomllib
23
+ from dataclasses import dataclass
24
+ from datetime import UTC, datetime
25
+ from pathlib import Path
26
+
27
+ from flow_cli.auth import default_profile_root, profile_dir, status
28
+
29
+ CONFIG_FILENAME = "config.toml"
30
+ PROFILE_DIR_PREFIX = "profile_"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ProfileMeta:
35
+ """Snapshot of one profile on disk."""
36
+
37
+ name: str
38
+ profile_dir: Path
39
+ cookies_present: bool
40
+ last_used_at: datetime | None
41
+ is_default: bool
42
+
43
+
44
+ class NoDefaultProfileError(RuntimeError):
45
+ """Raised when profile resolution can't pick exactly one profile."""
46
+
47
+ def __init__(self, available: list[str]):
48
+ self.available = available
49
+ msg = (
50
+ "Cannot pick a default profile.\n"
51
+ f"Available: {', '.join(available) if available else '(none)'}\n"
52
+ "Run `gflow auth use <name>`, set FLOW_CLI_PROFILE, or pass --profile."
53
+ )
54
+ super().__init__(msg)
55
+
56
+
57
+ class NoProfilesError(RuntimeError):
58
+ """Raised when no profiles exist at all (caller should trigger login)."""
59
+
60
+
61
+ def config_path() -> Path:
62
+ """Path to the user-level config.toml (under $FLOW_CLI_HOME)."""
63
+ return default_profile_root() / CONFIG_FILENAME
64
+
65
+
66
+ def list_profiles() -> list[ProfileMeta]:
67
+ """Discover every `profile_*` directory under $FLOW_CLI_HOME.
68
+
69
+ Returns them sorted by name. Each entry includes whether it has a Chromium
70
+ cookies file (a coarse "has session" probe — actual validity is only known
71
+ by hitting the live API).
72
+ """
73
+ root = default_profile_root()
74
+ if not root.exists():
75
+ return []
76
+ default_name = _read_default_profile_name()
77
+ out: list[ProfileMeta] = []
78
+ for entry in sorted(root.iterdir()):
79
+ if not entry.is_dir() or not entry.name.startswith(PROFILE_DIR_PREFIX):
80
+ continue
81
+ name = entry.name[len(PROFILE_DIR_PREFIX) :]
82
+ s = status(name)
83
+ last_used = _last_modified(entry)
84
+ out.append(
85
+ ProfileMeta(
86
+ name=name,
87
+ profile_dir=entry,
88
+ cookies_present=bool(s["cookies_present"]),
89
+ last_used_at=last_used,
90
+ is_default=(name == default_name),
91
+ )
92
+ )
93
+ return out
94
+
95
+
96
+ def has_any_profiles() -> bool:
97
+ return len(list_profiles()) > 0
98
+
99
+
100
+ def get_default_profile() -> str | None:
101
+ """Resolved default profile name, or None if no rule applies.
102
+
103
+ Order:
104
+ 1. config.toml `default_profile`
105
+ 2. Auto: if exactly one profile exists, that one is the de-facto default.
106
+ 3. None.
107
+ """
108
+ explicit = _read_default_profile_name()
109
+ if explicit:
110
+ return explicit
111
+ profiles = list_profiles()
112
+ if len(profiles) == 1:
113
+ return profiles[0].name
114
+ return None
115
+
116
+
117
+ def set_default_profile(name: str) -> Path:
118
+ """Persist `name` as the default profile in config.toml. Returns config path.
119
+
120
+ Validates the profile dir exists; raises FileNotFoundError otherwise so
121
+ typos don't silently set an unusable default.
122
+ """
123
+ pdir = profile_dir(name)
124
+ if not pdir.exists():
125
+ raise FileNotFoundError(
126
+ f"Profile dir not found: {pdir}\nRun `gflow auth login --profile {name}` first."
127
+ )
128
+ cfg = config_path()
129
+ cfg.parent.mkdir(parents=True, exist_ok=True)
130
+ # Single-key file — keep it minimal so future keys can be added cleanly.
131
+ existing = _load_config()
132
+ existing["default_profile"] = name
133
+ cfg.write_text(_dump_config(existing), encoding="utf-8")
134
+ return cfg
135
+
136
+
137
+ def clear_default_profile() -> None:
138
+ """Remove the default_profile key. Other config keys (future) preserved."""
139
+ cfg = config_path()
140
+ if not cfg.exists():
141
+ return
142
+ existing = _load_config()
143
+ existing.pop("default_profile", None)
144
+ if existing:
145
+ cfg.write_text(_dump_config(existing), encoding="utf-8")
146
+ else:
147
+ cfg.unlink()
148
+
149
+
150
+ def delete_profile(name: str) -> Path:
151
+ """Hard-delete the profile dir. Clears it as default if it was set."""
152
+ pdir = profile_dir(name)
153
+ if not pdir.exists():
154
+ raise FileNotFoundError(f"Profile dir not found: {pdir}")
155
+ shutil.rmtree(pdir, ignore_errors=False)
156
+ if _read_default_profile_name() == name:
157
+ clear_default_profile()
158
+ return pdir
159
+
160
+
161
+ def resolve_profile(cli_flag: str | None) -> str:
162
+ """Apply the full precedence chain. Raises if no profile can be picked."""
163
+ if cli_flag:
164
+ return cli_flag
165
+ env = os.environ.get("FLOW_CLI_PROFILE")
166
+ if env:
167
+ return env
168
+ default = get_default_profile()
169
+ if default:
170
+ return default
171
+ profiles = list_profiles()
172
+ if not profiles:
173
+ raise NoProfilesError("No profiles found. Run `gflow auth login` to create one.")
174
+ raise NoDefaultProfileError([p.name for p in profiles])
175
+
176
+
177
+ # --- internals --------------------------------------------------------------
178
+
179
+
180
+ def _read_default_profile_name() -> str | None:
181
+ cfg = _load_config()
182
+ val = cfg.get("default_profile")
183
+ return val if isinstance(val, str) and val else None
184
+
185
+
186
+ def _load_config() -> dict[str, object]:
187
+ cfg = config_path()
188
+ if not cfg.exists():
189
+ return {}
190
+ try:
191
+ with cfg.open("rb") as f:
192
+ return dict(tomllib.load(f))
193
+ except (tomllib.TOMLDecodeError, OSError):
194
+ return {}
195
+
196
+
197
+ def _dump_config(data: dict[str, object]) -> str:
198
+ """Tiny TOML serialiser — only handles flat string keys (sufficient for now).
199
+
200
+ Avoids adding `tomli-w` as a dependency. Switch to it if config grows
201
+ nested tables or non-string values.
202
+ """
203
+ lines: list[str] = []
204
+ for key, value in sorted(data.items()):
205
+ if not isinstance(value, str):
206
+ raise TypeError(
207
+ f"Only string values are supported in config.toml; "
208
+ f"got {type(value).__name__} for key {key!r}."
209
+ )
210
+ # Escape backslashes and double-quotes in TOML basic string.
211
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
212
+ lines.append(f'{key} = "{escaped}"')
213
+ return "\n".join(lines) + "\n"
214
+
215
+
216
+ def _last_modified(path: Path) -> datetime | None:
217
+ try:
218
+ # Best-effort: latest mtime among the cookies file or the dir itself.
219
+ candidates = [path]
220
+ for sub in (path / "Default" / "Cookies", path / "Cookies"):
221
+ if sub.exists():
222
+ candidates.append(sub)
223
+ ts = max(p.stat().st_mtime for p in candidates)
224
+ return datetime.fromtimestamp(ts, tz=UTC)
225
+ except OSError:
226
+ return None