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/__init__.py +3 -0
- flow_cli/__main__.py +6 -0
- flow_cli/api/__init__.py +18 -0
- flow_cli/api/client.py +246 -0
- flow_cli/api/dto.py +137 -0
- flow_cli/api/recaptcha.py +107 -0
- flow_cli/api/routes.py +37 -0
- flow_cli/api/video.py +112 -0
- flow_cli/auth.py +87 -0
- flow_cli/cli.py +184 -0
- flow_cli/cli_video.py +322 -0
- flow_cli/config.py +116 -0
- flow_cli/manifest.py +58 -0
- flow_cli/paths.py +82 -0
- flow_cli/profile_store.py +226 -0
- gflow_cli-0.2.0a1.dist-info/METADATA +404 -0
- gflow_cli-0.2.0a1.dist-info/RECORD +20 -0
- gflow_cli-0.2.0a1.dist-info/WHEEL +4 -0
- gflow_cli-0.2.0a1.dist-info/entry_points.txt +3 -0
- gflow_cli-0.2.0a1.dist-info/licenses/LICENSE +21 -0
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
|