diff --git a/pyproject.toml b/pyproject.toml index b82cfdd2a..e589dd6d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,12 @@ chain = [ patchright = [ "patchright==1.60.1", ] +# Camoufox engine extra — opt-in, stealth-oriented Firefox build that avoids +# bot detection and WAFs. Selected via GFLOW_CLI_BROWSER_ENGINE=camoufox. +# Requires separate installation: `uv pip install gflow-cli[camoufox]`. +camoufox = [ + "camoufox", +] [project.scripts] gflow = "gflow_cli.cli:main" diff --git a/src/gflow_cli/api/_engine.py b/src/gflow_cli/api/_engine.py index 9f8d771a6..0d1512d89 100644 --- a/src/gflow_cli/api/_engine.py +++ b/src/gflow_cli/api/_engine.py @@ -101,10 +101,20 @@ def retryable_engine_errors() -> tuple[type[BaseException], ...]: errs: list[type[BaseException]] = [PwError, PwTimeout] try: mod = importlib.import_module("patchright.async_api") + errs.append(cast("type[BaseException]", mod.Error)) + errs.append(cast("type[BaseException]", mod.TimeoutError)) except ImportError: - return tuple(errs) - errs.append(cast("type[BaseException]", mod.Error)) - errs.append(cast("type[BaseException]", mod.TimeoutError)) + pass + + try: + c_mod = importlib.import_module("camoufox.async_api") + if hasattr(c_mod, "Error"): + errs.append(cast("type[BaseException]", c_mod.Error)) + if hasattr(c_mod, "TimeoutError"): + errs.append(cast("type[BaseException]", c_mod.TimeoutError)) + except ImportError: + pass + return tuple(errs) diff --git a/src/gflow_cli/api/client.py b/src/gflow_cli/api/client.py index 6bed86918..0a01a964f 100644 --- a/src/gflow_cli/api/client.py +++ b/src/gflow_cli/api/client.py @@ -250,9 +250,11 @@ def __init__( settings: Settings | None = None, transport: FlowTransportStrategy | str | None = None, out_dir: Path | None = None, + mcp_warmup: bool = False, ) -> None: self.profile_dir = profile_dir self.headless = headless + self.mcp_warmup = mcp_warmup # Optional directory for debug screenshots when a generation step # fails on a selector. Propagated to the transport (see __aenter__) # so long-lived workers can diagnose a "Could not find ... CTA" @@ -278,6 +280,12 @@ def __init__( self._access_token_exp: float = 0.0 # epoch seconds; 0 = unknown/expired self._pw: Playwright | None = None self._context: BrowserContext | None = None + # Camoufox CM — set only when GFLOW_CLI_BROWSER_ENGINE=camoufox. + # AsyncCamoufox is its own async context manager that yields a + # BrowserContext directly (no separate Playwright driver). The CM is + # stored here so _close_browser_resources can __aexit__ it instead of + # calling self._pw.stop() (which would be None on this path). + self._camoufox_cm: Any | None = None # Per-worker Page pool (Phase 4 T2). All Pages live inside ONE # persistent BrowserContext and therefore SHARE cookies + auth state # at the Context level — this is intentional and matches Playwright's @@ -298,20 +306,44 @@ def __init__( # --- lifecycle -------------------------------------------------------- async def __aenter__(self) -> Self: - # --- Step 1: Launch Playwright FIRST so self._page is ready before + # --- Step 1: Launch the browser driver so self._page is ready before # transport.setup() is called. This order is load-bearing for S1 # (EvaluateFetchTransport): it needs a live Page passed via the # ``page=`` kwarg so it can reuse the client's context instead of # opening a second Playwright process against the same profile dir # (which would conflict on the Chromium lockfile — spec § 5.4.4). - # Engine selection: the default (playwright) path uses this module's - # ``async_playwright`` symbol unchanged — byte-identical behaviour and the - # existing test monkeypatches still apply. Only the opt-in patchright - # engine routes through the resolver (which raises a typed exit-24 error - # if the optional package is missing). + # Engine selection: + # - playwright (default): uses the module-level async_playwright symbol; + # test monkeypatches still apply — byte-identical behaviour. + # - patchright: opt-in drop-in Chromium variant, resolved via the + # resolver (raises exit-24 if the optional package is missing). + # - camoufox: opt-in stealth Firefox variant; AsyncCamoufox is its own + # async CM that yields a BrowserContext directly (no separate + # playwright driver). self._pw stays None; self._camoufox_cm holds + # the CM so _close_browser_resources can exit it cleanly. engine = active_engine() log_engine_selected(engine) - if engine == BrowserEngine.PATCHRIGHT: + if engine == BrowserEngine.CAMOUFOX: + try: + from camoufox.async_api import AsyncCamoufox + except ImportError as exc: + from gflow_cli.errors import BrowserEngineUnavailableError + + raise BrowserEngineUnavailableError( + detail="the 'camoufox' package is not installed", + remediation_hint=( + "Install it with `pip install camoufox` or " + "`uv pip install gflow-cli[camoufox]`." + ), + ) from exc + cm = AsyncCamoufox( + persistent_context=True, + user_data_dir=str(self.profile_dir), + headless=self.headless, + ) + self._camoufox_cm = cm + self._context = cast("BrowserContext", await cm.__aenter__()) + elif engine == BrowserEngine.PATCHRIGHT: self._pw = await resolve_async_playwright(engine)().start() else: self._pw = await async_playwright().start() @@ -532,32 +564,42 @@ async def _ensure_context_session_cookie(self) -> None: logger.warning("client.context_cookie_seed_error", error=type(exc).__name__) async def _enter_setup(self) -> None: - """Body of __aenter__ after the Playwright driver starts. + """Body of __aenter__ after the browser driver starts. Extracted so __aenter__ can wrap it in a partial-setup leak guard (a failed launch must not orphan a chrome process). + + Camoufox path: ``self._context`` is already set by ``__aenter__`` + (AsyncCamoufox yields the context directly); this method skips the + chromium launch and the navigator.webdriver patch (Camoufox spoofs + it natively) and proceeds directly to page-pool setup. """ - # Invariant: __aenter__ sets self._pw immediately before calling us. - assert self._pw is not None - # issue #222 (macOS): pre-read the profile's Flow cookies BEFORE launching - # the headed context, so _ensure_context_session_cookie can seed them if the - # headed context can't decrypt the on-disk store. Must run pre-launch — the - # snapshot's fallback opens a headless context on the same profile, which - # would deadlock on the singleton lock once the headed context holds it. - await self._preread_flow_session_cookies() - kwargs = self._persistent_context_kwargs() - self._log_and_guard_launch(kwargs) - self._context = await self._pw.chromium.launch_persistent_context(**kwargs) - # Hide the automation flag so reCAPTCHA Enterprise doesn't score - # the session as a bot — navigator.webdriver=true causes low-score - # tokens and HTTP 403 on batchGenerateImages. - await self._context.add_init_script( - "Object.defineProperty(navigator,'webdriver',{get:()=>undefined})", - ) - # issue #222: log whether the headed context loaded the Flow session - # cookie and, if not (macOS can't decrypt the on-disk store), seed it - # from the pre-launch snapshot. - await self._ensure_context_session_cookie() + engine = active_engine() + if engine == BrowserEngine.CAMOUFOX: + # Context was already opened in __aenter__ via AsyncCamoufox. + assert self._context is not None + else: + # Invariant for Playwright/Patchright: __aenter__ sets self._pw. + assert self._pw is not None + # issue #222 (macOS): pre-read the profile's Flow cookies BEFORE launching + # the headed context, so _ensure_context_session_cookie can seed them if the + # headed context can't decrypt the on-disk store. Must run pre-launch — the + # snapshot's fallback opens a headless context on the same profile, which + # would deadlock on the singleton lock once the headed context holds it. + await self._preread_flow_session_cookies() + kwargs = self._persistent_context_kwargs() + self._log_and_guard_launch(kwargs) + self._context = await self._pw.chromium.launch_persistent_context(**kwargs) + # Hide the automation flag so reCAPTCHA Enterprise doesn't score + # the session as a bot — navigator.webdriver=true causes low-score + # tokens and HTTP 403 on batchGenerateImages. + await self._context.add_init_script( + "Object.defineProperty(navigator,'webdriver',{get:()=>undefined})", + ) + # issue #222: log whether the headed context loaded the Flow session + # cookie and, if not (macOS can't decrypt the on-disk store), seed it + # from the pre-launch snapshot. + await self._ensure_context_session_cookie() # Open ``Settings.concurrency`` Pages inside the one persistent # BrowserContext. ``launch_persistent_context`` opens one Page by # default; reuse it as slot 0 to avoid an unused N+1 Page. @@ -580,6 +622,30 @@ async def _enter_setup(self) -> None: # Back-compat alias for callers that still touch ``self._page`` # directly. T3 removes the field entirely. self._page = self._pages[0] + + if self.mcp_warmup: + try: + logger.info("client.mcp_warmup_start") + await self._page.goto( + "https://www.google.com", + wait_until="domcontentloaded", + timeout=45_000, + ) + await self._page.fill("textarea[name='q'], input[name='q']", "Google Flow") + await self._page.keyboard.press("Enter") + await self._page.wait_for_selector("a[href*='labs.google']", timeout=15000) + links = await self._page.locator("a[href*='labs.google']").all() + if links: + await links[0].click() + await self._page.wait_for_url( + lambda url: "labs.google/fx/tools/flow" in url, + timeout=15000, + ) + else: + logger.warning("client.mcp_warmup_no_links") + except Exception as exc: + logger.warning("client.mcp_warmup_error", error=str(exc)) + # Bootstrap navigation so cookies + JS context are loaded before any # API call. Many endpoints 401 if you POST cold without an active page. # (Phase 3 deferred ``_new_session_id`` flake is addressed in T3 by @@ -646,7 +712,7 @@ async def __aexit__(self, *exc: object) -> None: logger.warning("transport_teardown_error", exc_info=True) async def _close_browser_resources(self) -> None: - """Close the BrowserContext and stop the Playwright driver, best-effort. + """Close the BrowserContext and stop the browser driver, best-effort. Shared by :meth:`__aexit__` and :meth:`__aenter__`'s partial-setup guard so a failed launch can't orphan a chrome process that then locks @@ -654,19 +720,32 @@ async def _close_browser_resources(self) -> None: still runs the next; errors surface as warnings (CLAUDE.md: never silently swallow). Resets the browser fields so a reused client never holds references to a dead BrowserContext. + + Camoufox path: the context is owned by ``self._camoufox_cm``; exiting + the CM closes both context and the Firefox process in one call. The + explicit ``self._context.close()`` is skipped to avoid a double-close. """ - if self._context is not None: - try: - await self._context.close() - except Exception: - # Browser cleanup is best-effort but MUST be surfaced for - # diagnosis (CLAUDE.md: never silently swallow errors). - logger.warning("browser_context_close_error", exc_info=True) - if self._pw is not None: + if self._camoufox_cm is not None: + # Camoufox CM owns the context; exiting it closes both context and + # the underlying Firefox process. try: - await self._pw.stop() + await self._camoufox_cm.__aexit__(None, None, None) except Exception: - logger.warning("playwright_stop_error", exc_info=True) + logger.warning("camoufox_context_close_error", exc_info=True) + self._camoufox_cm = None + else: + if self._context is not None: + try: + await self._context.close() + except Exception: + # Browser cleanup is best-effort but MUST be surfaced for + # diagnosis (CLAUDE.md: never silently swallow errors). + logger.warning("browser_context_close_error", exc_info=True) + if self._pw is not None: + try: + await self._pw.stop() + except Exception: + logger.warning("playwright_stop_error", exc_info=True) self._pages = [] self._page_queue = None self._page = None @@ -968,14 +1047,60 @@ async def _run_with_retry(self, attempt: Any, *, route: str) -> Any: # --- public API ------------------------------------------------------- async def create_project(self, title: str | None = None) -> ProjectInfo: - """Bootstrap a fresh Flow project. Title defaults to a timestamp. + """Bootstrap a fresh Flow project via UI automation. - Maps to `POST .../trpc/project.createProject`. + Navigates to the editor bootstrap URL, clicks the "New Project" button + (localized via the 'add_2' Google symbol icon), and intercepts the + tRPC createProject response. """ title = title or _default_project_title() - body = {"json": {"projectTitle": title, "toolName": "PINHOLE"}} - data = await self._post_json(routes.CREATE_PROJECT, body, content_type=_APPLICATION_JSON) - return ProjectInfo.from_create_response(data) + page = await self._checkout_page() + try: + # 1. Ensure we are on the flow tools page (where the New Project button lives) + if not page.url.startswith("https://labs.google/fx/tools/flow"): + await page.goto( + routes.EDITOR_BOOTSTRAP_URL, + wait_until="domcontentloaded", + timeout=60_000, + ) + + # Wait for button to be visible + btn = page.locator('button:has(i.google-symbols:has-text("add_2"))').first + await btn.wait_for(state="visible", timeout=30_000) + + # Intercept the createProject tRPC response + async with page.expect_response( + lambda r: "project.createProject" in r.url and r.status == 200, timeout=30_000 + ) as response_info: + await btn.click() + + response = await response_info.value + try: + raw_data = await response.json() + if isinstance(raw_data, list) and raw_data: + data_dict = cast("dict[str, Any]", raw_data[0]) + else: + data_dict = cast("dict[str, Any]", raw_data) + return ProjectInfo.from_create_response(data_dict) + except Exception as e: + # Fallback: if interception parsing fails, attempt to extract from the new URL + import re + + try: + await page.wait_for_url(re.compile(r"/project/([^/?]+)"), timeout=15_000) + except Exception: + pass + m = re.search(r"/project/([^/?]+)", page.url) + if m: + return ProjectInfo(project_id=m.group(1), title=title) + raise WireFormatError( + detail=f"Failed to parse project creation response or URL: {e}", + status=200, + instance=_make_instance(), + route="project.createProject", + ) from e + finally: + self._checkin_page(page) async def upload_image(self, project_id: str, image_path: Path) -> AssetInfo: """Upload an image into a Flow project's library. @@ -1483,7 +1608,11 @@ async def _drive_images_generation( raise RuntimeError( msg, ) - token = await self._mint_recaptcha_token(recaptcha_action) + if self.transport.name == "ui_automation": + token = "" + else: + token = await self._mint_recaptcha_token(recaptcha_action) + req_with_token = _dc_replace(req, recaptcha_token=token) images = await self.transport.generate_images( project_id=project_id, diff --git a/src/gflow_cli/api/dto.py b/src/gflow_cli/api/dto.py index 238c8cb81..bc9fc4fd6 100644 --- a/src/gflow_cli/api/dto.py +++ b/src/gflow_cli/api/dto.py @@ -128,6 +128,7 @@ class GeneratedImage: fife_url: str # CDN URL — usually expires after ~6 hours dimensions: tuple[int, int] # (width, height) media_generation_id: str | None = None + display_name: str | None = None @property def is_signed_url(self) -> bool: @@ -172,8 +173,34 @@ def from_response_dict(cls, data: dict[str, Any]) -> list[GeneratedImage]: if not isinstance(media, list): msg = "unexpected batchGenerateImages response shape: media is not a list" raise ValueError(msg) + workflows = data.get("workflows", []) + workflow_map: dict[str, str] = {} + if isinstance(workflows, list): + workflows_list = cast("list[Any]", workflows) + for w in workflows_list: + if not isinstance(w, dict): + continue + w_dict = cast("dict[str, Any]", w) + w_id = w_dict.get("name") + if isinstance(w_id, str): + metadata = w_dict.get("metadata") + if isinstance(metadata, dict): + metadata_dict = cast("dict[str, Any]", metadata) + display_name = metadata_dict.get("displayName") + if isinstance(display_name, str): + workflow_map[w_id.split("/")[-1]] = display_name + items = cast("list[dict[str, Any]]", media) - return [cls.from_response_item(item) for item in items] + result: list[GeneratedImage] = [] + for item in items: + img = cls.from_response_item(item) + w_id = img.workflow_id.split("/")[-1] + if w_id in workflow_map: + from dataclasses import replace + + img = replace(img, display_name=workflow_map[w_id]) + result.append(img) + return result @dataclass(frozen=True) diff --git a/src/gflow_cli/api/image.py b/src/gflow_cli/api/image.py index e138d389e..73abe30cd 100644 --- a/src/gflow_cli/api/image.py +++ b/src/gflow_cli/api/image.py @@ -227,6 +227,9 @@ class GenerateImageRequest: aspect: Aspect = Aspect.PORTRAIT model: Model = Model.NARWHAL refs: tuple[ImageRef, ...] = () + # Display names corresponding to the pre-uploaded UUID `refs`. Attached via the + # Flow UI picker by the ui_automation transport. + ref_names: tuple[str, ...] = () # Local reference-image files for I2I — attached through the editor's media # dialog by the ui_automation transport (the REST uploadImage path 401s). # The common i2i case; `refs` (pre-uploaded UUIDs) would need library-select. diff --git a/src/gflow_cli/api/transports/drivers/agentic.py b/src/gflow_cli/api/transports/drivers/agentic.py index 6249636e4..8e9943118 100644 --- a/src/gflow_cli/api/transports/drivers/agentic.py +++ b/src/gflow_cli/api/transports/drivers/agentic.py @@ -307,7 +307,14 @@ async def send_prompt( # is the proven Slate path (mirrors classic _send_prompt). await page.keyboard.press("Control+a") await page.keyboard.press("Delete") - await page.keyboard.insert_text(directive) + + from gflow_cli.config import BrowserEngine, get_settings + is_camoufox = get_settings().browser_engine == BrowserEngine.CAMOUFOX + + if is_camoufox: + await page.keyboard.type(directive) + else: + await page.keyboard.insert_text(directive) log.debug( "agentic_driver.send_prompt.typed", diff --git a/src/gflow_cli/api/transports/ui_automation.py b/src/gflow_cli/api/transports/ui_automation.py index 30668d95b..20864028f 100644 --- a/src/gflow_cli/api/transports/ui_automation.py +++ b/src/gflow_cli/api/transports/ui_automation.py @@ -494,15 +494,37 @@ def _extract_project_id(url: str) -> str | None: def _collect_images_from_body(body: dict[str, Any], images: list[GeneratedImage]) -> None: """Append parseable GeneratedImage entries from one batchGenerateImages body.""" + workflows = body.get("workflows", []) + workflow_map: dict[str, str] = {} + if isinstance(workflows, list): + workflows_list = cast("list[Any]", workflows) + for w in workflows_list: + if not isinstance(w, dict): + continue + w_dict = cast("dict[str, Any]", w) + w_id = w_dict.get("name") + if isinstance(w_id, str): + metadata = w_dict.get("metadata") + if isinstance(metadata, dict): + metadata_dict = cast("dict[str, Any]", metadata) + display_name = metadata_dict.get("displayName") + if isinstance(display_name, str): + workflow_map[w_id.split("/")[-1]] = display_name + media_list_raw = body.get("media", []) if not isinstance(media_list_raw, list): return - for item_raw in cast(_AnyList, media_list_raw): + for item_raw in cast("list[Any]", media_list_raw): if not isinstance(item_raw, dict): continue - item: dict[str, Any] = cast(_JsonObj, item_raw) + item: dict[str, Any] = cast("dict[str, Any]", item_raw) try: - images.append(GeneratedImage.from_response_item(item)) + img = GeneratedImage.from_response_item(item) + w_id = img.workflow_id.split("/")[-1] + if w_id in workflow_map: + from dataclasses import replace + img = replace(img, display_name=workflow_map[w_id]) + images.append(img) except ValueError as e: log.warning("ui_automation.parse_media_item_failed", error=str(e)) @@ -740,51 +762,79 @@ async def setup(self, profile_dir: Path, *, page: Page | None = None) -> None: engine = active_engine() log_engine_selected(engine) - if engine == BrowserEngine.PATCHRIGHT: - pw_factory = resolve_async_playwright(engine) - elif async_playwright is None: # pragma: no cover — install-time guard - msg = ( - "Playwright is required for UiAutomationTransport. " - "Install via `uv sync` (it is a runtime dependency)." - ) - raise RuntimeError( - msg, - ) - else: - pw_factory = async_playwright - pw_cm = pw_factory() - # The two engines (playwright | patchright) expose a structurally identical - # ``.chromium.launch_persistent_context`` surface; type as Any so this - # standalone path is engine-agnostic without per-engine stubs. - pw: Any = await pw_cm.__aenter__() - try: - import os + import os + + from gflow_cli.browser_manager import channel_for_profile - from gflow_cli.browser_manager import channel_for_profile + locale_env = os.getenv("GFLOW_CLI_LOCALE", "en-US") + # Playwright/Patchright driver handle — None for Camoufox (which has no + # separate driver; AsyncCamoufox yields the BrowserContext directly). + pw: Any = None - locale_env = os.getenv("GFLOW_CLI_LOCALE", "en-US") - ctx = await pw.chromium.launch_persistent_context( - str(profile_dir), + if engine == BrowserEngine.CAMOUFOX: + try: + from camoufox.async_api import AsyncCamoufox + except ImportError as exc: + from gflow_cli.errors import BrowserEngineUnavailableError + + raise BrowserEngineUnavailableError( + detail="the 'camoufox' package is not installed", + remediation_hint=( + "Install it with `pip install camoufox`" + " or `uv pip install gflow-cli[camoufox]`." + ), + ) from exc + + pw_cm = AsyncCamoufox( + persistent_context=True, + user_data_dir=str(profile_dir), headless=False, - viewport=cast("ViewportSize", _VIEWPORT), - locale=locale_env, - channel=channel_for_profile(profile_dir), - args=[ - "--disable-blink-features=AutomationControlled", - "--password-store=basic", - "--disable-dev-shm-usage", - ], - ) - # Hide the automation flag so reCAPTCHA Enterprise doesn't score - # the session as a bot — navigator.webdriver=true causes low-score - # tokens and HTTP 403 on batchGenerateImages. - await ctx.add_init_script( - "Object.defineProperty(navigator,'webdriver',{get:()=>undefined})", ) + # AsyncCamoufox is its own async CM that yields a BrowserContext + # directly — there is no separate Playwright driver object here. + ctx = await pw_cm.__aenter__() + else: + if engine == BrowserEngine.PATCHRIGHT: + pw_factory = resolve_async_playwright(engine) + elif async_playwright is None: # pragma: no cover — install-time guard + msg = ( + "Playwright is required for UiAutomationTransport. " + "Install via `uv sync` (it is a runtime dependency)." + ) + raise RuntimeError(msg) + else: + pw_factory = async_playwright + + pw_cm = pw_factory() + pw = await pw_cm.__aenter__() + ctx = None + + try: + if engine != BrowserEngine.CAMOUFOX: + ctx = await pw.chromium.launch_persistent_context( + str(profile_dir), + headless=False, + viewport=cast("ViewportSize", _VIEWPORT), + locale=locale_env, + channel=channel_for_profile(profile_dir), + args=[ + "--disable-blink-features=AutomationControlled", + "--password-store=basic", + "--disable-dev-shm-usage", + ], + ) + # Hide the automation flag so reCAPTCHA Enterprise doesn't score + # the session as a bot — navigator.webdriver=true causes low-score + # tokens and HTTP 403 on batchGenerateImages. + await ctx.add_init_script( + "Object.defineProperty(navigator,'webdriver',{get:()=>undefined})", + ) + self._pw_cm = pw_cm self._ctx = ctx - page = cast("Page", ctx.pages[0] if ctx.pages else await ctx.new_page()) + any_ctx = cast("Any", ctx) + page = cast("Page", any_ctx.pages[0] if any_ctx.pages else await any_ctx.new_page()) self._page = page try: await page.goto(FLOW_URL, wait_until="networkidle", timeout=45_000) @@ -989,7 +1039,18 @@ async def _enter_editor( if project_id: url = routes.project_editor_url(locale, project_id) log.info("ui_automation.entering_existing_project", project_id=project_id, url=url) - await page.goto(url, wait_until="domcontentloaded", timeout=45_000) + try: + if project_id in page.url: + log.debug("ui_automation.hard_reload_existing_project", project_id=project_id) + await page.goto(page.url, wait_until="domcontentloaded", timeout=45_000) + else: + await page.goto(url, wait_until="domcontentloaded", timeout=45_000) + except Exception as e: + if "NS_BINDING_ABORTED" in str(e): + log.debug("ui_automation.goto_binding_aborted_ignored", url=url) + await page.wait_for_timeout(2000) + else: + raise await self._dismiss_blocking_overlays(page, out_dir) return @@ -1003,7 +1064,14 @@ async def _enter_editor( # and networkidle is flaky. The selector wait_for below is the real # readiness gate. log.info("ui_automation.navigating_to_gallery", restored_url=page.url) - await page.goto(FLOW_URL, timeout=45_000) + try: + await page.goto(FLOW_URL, timeout=45_000) + except Exception as e: + if "NS_BINDING_ABORTED" in str(e): + log.debug("ui_automation.goto_binding_aborted_ignored", url=FLOW_URL) + await page.wait_for_timeout(2000) + else: + raise await self._bypass_onboarding(page) await page.wait_for_timeout(3000) @@ -1181,6 +1249,8 @@ async def _send_prompt( page: Page, prompt_text: str, out_dir: Path | None = None, + *, + fast: bool = False, ) -> None: """Type ``prompt_text`` into Flow's editor and submit. @@ -1207,9 +1277,27 @@ async def _send_prompt( await input_box.click() await page.keyboard.press("Control+A") await page.keyboard.press("Delete") - # insert_text fires a single beforeinput event that Slate.js handles - # natively — near-instant vs keyboard.type() which is ~1.5s/char. - await page.keyboard.insert_text(prompt_text) + + from gflow_cli.config import BrowserEngine, get_settings + is_camoufox = get_settings().browser_engine == BrowserEngine.CAMOUFOX + + if fast: + if is_camoufox: + await page.keyboard.type(prompt_text) + else: + await page.keyboard.insert_text(prompt_text) + await page.wait_for_timeout(100) + else: + import random + for char in prompt_text: + if char == "\n": + await page.keyboard.press("Enter") + else: + if is_camoufox: + await page.keyboard.type(char) + else: + await page.keyboard.insert_text(char) + await page.wait_for_timeout(random.randint(20, 80)) await page.wait_for_timeout(500) await self._click_submit(page) @@ -1249,12 +1337,26 @@ async def _submit_body_prompt( # Clear Flow's pre-filled (localized) template and replace it wholesale # with our self-contained triptych prompt. Slate.js needs real keyboard - # events — select-all + Delete + insert_text, the same path _send_prompt - # uses. + # events — select-all + Delete + insert_text. + # We simulate human typing with random delays. await input_box.click() await page.keyboard.press("Control+A") await page.keyboard.press("Delete") - await page.keyboard.insert_text(full_prompt) + + from gflow_cli.config import BrowserEngine, get_settings + is_camoufox = get_settings().browser_engine == BrowserEngine.CAMOUFOX + + import random + for char in full_prompt: + if char == "\n": + await page.keyboard.press("Enter") + else: + if is_camoufox: + await page.keyboard.type(char) + else: + await page.keyboard.insert_text(char) + await page.wait_for_timeout(random.randint(20, 80)) + await page.wait_for_timeout(500) log.info( @@ -2110,6 +2212,15 @@ async def _generate_images_locked( if request.ref_paths: await self._attach_references(page, list(request.ref_paths), out_dir=out_dir) + # Already-uploaded UUID references (ref_names) must be attached via the + # editor's media dialog using their display name, identical to video R2V. + if request.ref_names: + from gflow_cli.api.transports.ui_automation_video import VideoGenerationMixin + + await VideoGenerationMixin._attach_remote_references( + page, list(request.ref_names), out_dir=out_dir + ) + # Entity references: attach locked CHARACTER entities via the Personagens # picker (inherited from the video transport). The entity must live in the # project we generate in (pass --project / project_id). Flow's JS then @@ -2129,22 +2240,11 @@ async def _generate_images_locked( return await ui_driver.await_images(page, request.count, out_dir=out_dir) # Classic path: network-capture via response listener (unchanged). - # Attach the response listener SYNCHRONOUSLY before any prompt - # action. asyncio.create_task is unsafe here: it defers the listener - # registration until the new task gets event-loop scheduling, which - # could happen AFTER _send_prompt's click on a busy loop. Splitting - # attach/await eliminates that race. Project-ID filter prevents stale - # responses from previously-visited projects accumulating in the list. captured, detach = self._attach_batch_response_listener(page, project_id=nav_project_id) - # Also log the OUTGOING request body summary AND collect the entity ids - # it carries — the #170 submit backstop reads the sink after the run. request_bodies: list[dict[str, Any]] = [] req_log_detach = self._attach_batch_request_logger( page, project_id=nav_project_id, sink=request_bodies ) - # Record submit_time BEFORE the click so the post-submit-time filter - # in _await_captured can distinguish this prompt's responses from any - # stale entries that arrived between listener attach and the click. submit_time = time.monotonic() responses: list[dict[str, Any]] = [] try: @@ -2158,9 +2258,60 @@ async def _generate_images_locked( detach() req_log_detach() - # Collect images from ALL captured responses (Flow makes one API call - # per image when count > 1). - images, first_error_status, first_error_route = _images_from_responses(responses) + try: + images, first_error_status, first_error_route = _images_from_responses(responses) + except WafRejectionError: + log.warning( + "ui_automation.waf_retry", + note="WAF block encountered. Refreshing page and retrying with fast paste...", + ) + try: + await page.goto(page.url, wait_until="domcontentloaded") + except Exception as e: + if "NS_BINDING_ABORTED" in str(e): + log.debug("ui_automation.reload_binding_aborted_ignored") + await page.wait_for_timeout(2000) + else: + raise + await page.wait_for_timeout(3000) + await self._dismiss_blocking_overlays(page, out_dir) + + # Re-attach any reference images/entities that were cleared by the refresh + if request.ref_paths: + await self._attach_references(page, list(request.ref_paths), out_dir=out_dir) + + if request.ref_names: + from gflow_cli.api.transports.ui_automation_video import VideoGenerationMixin + + await VideoGenerationMixin._attach_remote_references( + page, list(request.ref_names), out_dir=out_dir + ) + + if request.reference_entities: + await self._attach_character_entities( + page, + zip_entity_refs(request.reference_entities, request.reference_entity_names), + out_dir=out_dir, + ) + + captured, detach = self._attach_batch_response_listener(page, project_id=nav_project_id) + req_log_detach = self._attach_batch_request_logger( + page, project_id=nav_project_id, sink=request_bodies + ) + submit_time = time.monotonic() + responses = [] + try: + await ui_driver.send_prompt(page, request.prompt, out_dir=out_dir) + responses = await self._await_captured( + captured, + expected_count=request.count, + submit_time=submit_time, + ) + finally: + detach() + req_log_detach() + + images, first_error_status, first_error_route = _images_from_responses(responses) if first_error_status is not None and not images: raise WireFormatError( @@ -2680,7 +2831,14 @@ async def _enter_character_editor( project_id=project_id, entity_id=entity_id, ) - await page.goto(url, wait_until="domcontentloaded", timeout=45_000) + try: + await page.goto(url, wait_until="domcontentloaded", timeout=45_000) + except Exception as e: + if "NS_BINDING_ABORTED" in str(e): + log.debug("ui_automation.goto_binding_aborted_ignored", url=url) + await page.wait_for_timeout(2000) + else: + raise await self._dismiss_blocking_overlays(page, self._out_dir) # Wait for the Slate editor to mount — the prompt textbox is the diff --git a/src/gflow_cli/api/transports/ui_automation_video.py b/src/gflow_cli/api/transports/ui_automation_video.py index 42990ef15..c629acf27 100644 --- a/src/gflow_cli/api/transports/ui_automation_video.py +++ b/src/gflow_cli/api/transports/ui_automation_video.py @@ -331,12 +331,21 @@ # preview, `arrow_drop_down` sort) — same situation as ADD_TO_PROMPT_DIALOG. # Tiers are probed sequentially (see _resolve_include_action), never flattened # into one comma list: comma lists resolve in DOM order, not tier priority. -PICKER_INCLUDE_BUTTON: tuple[str, str] = ( +PICKER_INCLUDE_BUTTON: tuple[str, str, str, str] = ( + "[role='dialog'][data-state='open'] button:has(div[data-type='button-overlay'])" + ":not(:has(i.google-symbols))", + "[role='dialog']:visible button:has(div[data-type='button-overlay'])" + ":not(:has(i.google-symbols))", "button:has-text('Incluir no comando'), button:has-text('Добавить в запрос')," " button:has-text('Add to prompt')", "[role='dialog'][data-state='open'] button:not(:has(i.google-symbols))", ) -_INCLUDE_BUTTON_TIER_NAMES = ("text", "structural") +_INCLUDE_BUTTON_TIER_NAMES = ( + "structural_overlay_open", + "structural_overlay_visible", + "text", + "structural_iconless", +) # Context-menu include action shown on RIGHT-CLICK of a Personagens entity # tile. This is what stages a `referenceEntity` (the inline Tudo button instead # stages a `referenceImage` of the thumbnail). Verified 2026-06-06. @@ -1181,6 +1190,13 @@ async def _pick_option_and_include( await tile.click() await page.wait_for_timeout(300) + # Check if the tile click auto-closed the picker (some Flow UI cohorts + # auto-attach and close on tile click, removing the "Add to Prompt" step). + dialog = page.locator(DIALOG_ANY).last + if not await dialog.is_visible(): + log.info("ui_automation_video.picker_auto_closed", surface=surface) + return + # Include via the tiered, locale-safe resolver (a hardcoded English # "Add to Prompt" here previously hung non-English accounts — #170/#56). include = await VideoGenerationMixin._resolve_include_action( @@ -1210,15 +1226,19 @@ async def _pick_option_and_include( def _remote_option_tile(page: Page, name: str) -> Locator: """Locate a picker result tile by its display name. - Uses ARIA role+name matching rather than - ``[role='option']:has-text('{name}')``: ``name`` is a stored - ``display_name`` or the original generation prompt, both of which - commonly contain an apostrophe or quote that would break a - single-quoted ``:has-text()`` CSS selector (PR #237 review). + Uses ARIA role + exact text node matching rather than + ``[role='option']:has-text('{name}')`` or ``name=name`` in get_by_role: + - name=name in get_by_role fails when the accessible name concatenates + other text (e.g. 'Icy planet in deep spaceImage' due to Flow's layout). + - :has-text() with string injection breaks on quotes/apostrophes. + - The exact=True text filter prevents 'cabin' from matching 'cabin at night' + (PR #245 review). """ - # exact=True: the default substring match would let 'cabin' select - # 'cabin at night' and .first attach the wrong image (PR #245 review). - return page.get_by_role("option", name=name, exact=True) + text_node = page.get_by_text(name, exact=True) + return ( + page.get_by_role("option").filter(has=text_node) + .or_(page.get_by_role("button").filter(has=text_node)) + ) @staticmethod async def _resolve_frame_slot( diff --git a/src/gflow_cli/auth/camoufox_strategy.py b/src/gflow_cli/auth/camoufox_strategy.py new file mode 100644 index 000000000..3ae01d8f5 --- /dev/null +++ b/src/gflow_cli/auth/camoufox_strategy.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any, cast + +import structlog +from rich.console import Console + +from gflow_cli.config import get_settings +from gflow_cli.errors import SecurityError + +from .base import AuthStrategy +from .internal_chromium import poll_session_until_authenticated + +if TYPE_CHECKING: + from pathlib import Path + +logger = structlog.get_logger(__name__) +_console = Console() + +GEMINI_URL = "https://labs.google/fx/tools/flow?hl=en" + + +class CamoufoxStrategy(AuthStrategy): + """Login strategy using Camoufox stealth browser. + + This bypasses Google's browser restrictions by spoofing fingerprints and + avoiding Playwright automation leaks. + """ + + name = "camoufox" + + def __init__(self, *, timeout_seconds: int = 600) -> None: + self._timeout_seconds = timeout_seconds + + async def login(self, profile_dir: Path, headless: bool) -> None: + """Execute the login flow using Camoufox.""" + settings = get_settings() + try: + profile_dir.resolve(strict=False).relative_to(settings.home.resolve()) + except ValueError: + msg = ( + f"Profile directory {profile_dir} is outside of GFLOW_CLI_HOME " + f"({settings.home}) boundaries." + ) + raise SecurityError(msg) from None + + profile_dir.mkdir(parents=True, exist_ok=True) + logger.info("auth_login_started", profile_dir=str(profile_dir), strategy=self.name) + + user_email: str | None = None + + try: + from camoufox.async_api import AsyncCamoufox + except ImportError as e: + from gflow_cli.errors import ConfigurationError + + raise ConfigurationError( + "Camoufox not installed. Run `uv pip install gflow-cli[camoufox]`" + " or `pip install camoufox`." + ) from e + + async with AsyncCamoufox( + persistent_context=True, + user_data_dir=str(profile_dir), + headless=headless, + ) as raw_ctx: + ctx = cast("Any", raw_ctx) + page = ctx.pages[0] if ctx.pages else await ctx.new_page() + await page.goto(GEMINI_URL, wait_until="domcontentloaded", timeout=60_000) + + if not headless: + _console.print( + "\n Sign into your Google account in the open Camoufox window.\n" + " Once you reach the Flow editor, gflow will automatically detect " + "success and exit.\n", + ) + + user_email = await poll_session_until_authenticated( + ctx, + page, + self._timeout_seconds, + self.name, + ) + await asyncio.sleep(1) + + if user_email: + (profile_dir / ".gflow_account").write_text(user_email, encoding="utf-8") + (profile_dir / ".gflow_browser_strategy").write_text("camoufox", encoding="utf-8") diff --git a/src/gflow_cli/auth/factory.py b/src/gflow_cli/auth/factory.py index fa05caa91..7d02a0605 100644 --- a/src/gflow_cli/auth/factory.py +++ b/src/gflow_cli/auth/factory.py @@ -38,8 +38,15 @@ def create(self, mode: str) -> AuthStrategy: ) return InternalChromiumStrategy(timeout_seconds=timeout) + if mode == "camoufox": + from .camoufox_strategy import CamoufoxStrategy + + return CamoufoxStrategy(timeout_seconds=timeout) + if mode not in self._strategies: - msg = f"Unknown auth browser mode '{mode}'. Supported: auto, chrome, internal." + msg = ( + f"Unknown auth browser mode '{mode}'. Supported: auto, chrome, internal, camoufox." + ) raise ConfigurationError( msg, ) diff --git a/src/gflow_cli/auth/internal_chromium.py b/src/gflow_cli/auth/internal_chromium.py index b7b37c586..19071ac3a 100644 --- a/src/gflow_cli/auth/internal_chromium.py +++ b/src/gflow_cli/auth/internal_chromium.py @@ -23,7 +23,7 @@ GOOGLE_REJECTED_BROWSER_ROUTE = "accounts.google.com/v3/signin/rejected" -async def _poll_session_until_authenticated( +async def poll_session_until_authenticated( ctx: Any, page: Any, timeout_seconds: int, @@ -163,7 +163,7 @@ async def login(self, profile_dir: Path, headless: bool) -> None: ) # Poll until the Flow app sign-in completes; raises on timeout/rejection. - user_email = await _poll_session_until_authenticated( + user_email = await poll_session_until_authenticated( ctx, page, self._timeout_seconds, diff --git a/src/gflow_cli/cli.py b/src/gflow_cli/cli.py index 835f2738d..df68b606c 100644 --- a/src/gflow_cli/cli.py +++ b/src/gflow_cli/cli.py @@ -198,8 +198,8 @@ def _maybe_rename_first_profile( @click.option( "--browser", default=None, - type=click.Choice(["auto", "chrome", "internal"], case_sensitive=False), - help="Browser strategy for login. 'chrome' bypasses Google secure blocks.", + type=click.Choice(["auto", "chrome", "internal", "camoufox"], case_sensitive=False), + help="Browser strategy for login. 'chrome' or 'camoufox' bypass Google secure blocks.", envvar="GFLOW_CLI_AUTH_BROWSER", ) def auth_login(profile: str | None, browser: str | None) -> None: @@ -216,6 +216,8 @@ def auth_login(profile: str | None, browser: str | None) -> None: console.print("Launching internal Chromium...") elif selected_browser == "chrome": console.print("Launching real Chrome...") + elif selected_browser == "camoufox": + console.print("Launching stealth Camoufox...") else: # auto console.print( "Launching real Chrome..." diff --git a/src/gflow_cli/config.py b/src/gflow_cli/config.py index 0fa861626..5a01d5c4c 100644 --- a/src/gflow_cli/config.py +++ b/src/gflow_cli/config.py @@ -126,12 +126,17 @@ class BrowserEngine(StrEnum): PLAYWRIGHT is the default and the only engine the standard install ships. PATCHRIGHT is an opt-in, drop-in patched Playwright (Chromium) that avoids the ``Runtime.enable`` CDP leak for stronger reCAPTCHA-Enterprise evasion on - the headed path; it must be installed separately (``pip install patchright``) + the HEADED path; it must be installed separately (``pip install patchright``) and is NOT a headless unlock. + + CAMOUFOX is an opt-in, stealth-oriented Firefox build that actively spoofs + fingerprints and removes CDP leaks to bypass bot detection and WAFs. + It must be installed separately (``pip install camoufox``). """ PLAYWRIGHT = "playwright" PATCHRIGHT = "patchright" + CAMOUFOX = "camoufox" class Settings(BaseSettings): @@ -292,7 +297,8 @@ def _validate_storage_uri(cls, v: object) -> object: "avoids the Runtime.enable CDP leak for stronger reCAPTCHA-Enterprise " "evasion on the HEADED path. It must be installed separately " "(`pip install patchright`) and is NOT a headless unlock — the default " - "stays playwright and is unaffected. Override via GFLOW_CLI_BROWSER_ENGINE." + "stays playwright and is unaffected. 'camoufox' is an anti-detect " + "stealth Firefox build. Override via GFLOW_CLI_BROWSER_ENGINE." ), ) prefer_classic: bool = Field( diff --git a/src/gflow_cli/data/recorder.py b/src/gflow_cli/data/recorder.py index 8e835ef7d..6aca919e3 100644 --- a/src/gflow_cli/data/recorder.py +++ b/src/gflow_cli/data/recorder.py @@ -245,6 +245,10 @@ def _persist_generated_image( media_type = mimetypes.guess_type(saved_path.name)[0] asset_id = _new_id() width, height = image.dimensions + metadata = {"fife_url": image.fife_url} + if image.display_name: + metadata["display_name"] = image.display_name + repo.upsert_asset( AssetRecord( id=asset_id, @@ -261,7 +265,7 @@ def _persist_generated_image( height=height, duration_seconds=None, seed=image.seed, - metadata_json=redact_metadata({"fife_url": image.fife_url}), + metadata_json=redact_metadata(metadata), ), ) repo.link_operation_asset(op_id, asset_id, OperationAssetRole.OUTPUT, i) diff --git a/src/gflow_cli/mcp/tools.py b/src/gflow_cli/mcp/tools.py index d4144fa51..f5c2917cc 100644 --- a/src/gflow_cli/mcp/tools.py +++ b/src/gflow_cli/mcp/tools.py @@ -24,6 +24,7 @@ import structlog from gflow_cli._cli_helpers import _FLOW_ID_RE +from gflow_cli.api.client import FlowApiClient from gflow_cli.config import get_settings from gflow_cli.data.queries import list_projects from gflow_cli.data.repository import DataRepository @@ -70,6 +71,85 @@ async def acquire(self) -> bool: _rate_limiter = _TokenBucket() + +class _SessionManager: + """Manages long-lived FlowApiClient sessions for MCP tools.""" + + def __init__(self) -> None: + self.clients: dict[str, FlowApiClient] = {} + self.last_used: dict[str, float] = {} + self._lock = asyncio.Lock() + self._monitor_task: asyncio.Task[None] | None = None + + async def get_client(self, profile: str) -> FlowApiClient: + from gflow_cli.config import get_settings + + async with self._lock: + if self._monitor_task is None: + self._monitor_task = asyncio.create_task(self._monitor_idle_clients()) + + client = self.clients.get(profile) + if client is not None: + try: + # If the page was manually closed, recreate the client. + if client.page.is_closed(): + await client.__aexit__(None, None, None) + client = None + except Exception: + try: + await client.__aexit__(None, None, None) + except Exception: + pass + client = None + + if client is None: + settings = get_settings() + profile_dir = settings.profile_subdir(profile) + client = FlowApiClient( + profile_dir=profile_dir, + settings=settings, + mcp_warmup=True, + ) + await client.__aenter__() + self.clients[profile] = client + + self.last_used[profile] = time.time() + return client + + async def release_client(self, profile: str) -> None: + async with self._lock: + if profile in self.last_used: + self.last_used[profile] = time.time() + + async def _monitor_idle_clients(self) -> None: + import os + while True: + await asyncio.sleep(60) + + idle_time_str = os.environ.get("MCP_IDLE_TIME", "300") + try: + idle_timeout = int(idle_time_str) + except ValueError: + idle_timeout = 300 + + if idle_timeout <= 0: + continue + + now = time.time() + async with self._lock: + for profile, client in list(self.clients.items()): + if now - self.last_used.get(profile, now) > idle_timeout: + try: + await client.__aexit__(None, None, None) + except Exception: + pass + self.clients.pop(profile, None) + self.last_used.pop(profile, None) + log.info("mcp.session.idle_timeout", profile=profile) + + +_session_manager = _SessionManager() + # Per-profile execution locks to prevent Playwright collisions _profile_locks: dict[str, asyncio.Lock] = {} @@ -239,9 +319,13 @@ async def _run_generation_task( ) # 2. Run the worker synchronously (we already hold the profile lock). + # We reuse a long-lived FlowApiClient via the SessionManager so we don't + # thrash browser contexts between consecutive batch calls. worker = FlowWorker(profile_name=profile, db_path=str(db_path)) try: - await worker.process_task(task) + client = await _session_manager.get_client(profile) + await worker.process_task(task, client=client) + await _session_manager.release_client(profile) finally: worker.close() @@ -384,10 +468,6 @@ def _resolve_ref_name( return ref_id, None -# Video task types whose remote refs the UI automation attaches by DISPLAY NAME -# (searched in the Flow picker) and therefore need UUID→name resolution. Image -# task types attach remote refs by raw media id and MUST NOT be resolved here — -# gating on that was the PR #245 image-i2i regression. _VIDEO_TASK_TYPES = frozenset({"t2v", "i2v", "r2v"}) @@ -399,29 +479,38 @@ def _resolve_payload_ref_names( ) -> dict[str, Any] | None: """Resolve every remote-ref field in ``payload`` to a display name in place. - Only the video task types need this (their refs are attached by display - name); image tasks attach remote refs by raw media id and are left - untouched. Returns an error envelope on the first unresolvable UUID, else - ``None``. + Video task types require this (their refs are attached by display name in the + Flow picker). Image task types also resolve when possible to support the UI + automation transport, but fail gracefully (pass through) if the UUID is missing + from the local catalog to satisfy PR #245 image-i2i requirements. + + Returns an error envelope on the first unresolvable UUID for video tasks, + else ``None``. """ - if task_type not in _VIDEO_TASK_TYPES: - return None + is_video = task_type in _VIDEO_TASK_TYPES if "refs" in payload: ref_names: list[str] = [] for ref in payload["refs"]: name, err = _resolve_ref_name(data_repo, profile, ref) if err is not None: - return err + if is_video: + return err + # For image tasks, if missing from catalog, pass through silently + # to satisfy PR #245 image-i2i requirements. + continue # name is never falsy when err is None (see _resolve_ref_name). assert name is not None ref_names.append(name) - payload["ref_names"] = ref_names - for key in ("start_image_ref", "end_image_ref"): - if key in payload: - name, err = _resolve_ref_name(data_repo, profile, payload[key]) - if err is not None: - return err - payload[f"{key}_name"] = name + if ref_names: + payload["ref_names"] = ref_names + + if is_video: + for key in ("start_image_ref", "end_image_ref"): + if key in payload: + name, err = _resolve_ref_name(data_repo, profile, payload[key]) + if err is not None: + return err + payload[f"{key}_name"] = name return None @@ -681,6 +770,9 @@ async def gflow_generate_video( prompt: str, mode: str = "t2v", aspect: str = "9:16", + model: str | None = None, + duration: int | None = None, + count: int = 1, initial_frame: str | None = None, end_frame: str | None = None, reference_images: list[str] | None = None, @@ -694,6 +786,9 @@ async def gflow_generate_video( prompt: The text prompt describing the desired video. mode: Generation mode — 't2v', 'i2v', or 'r2v'. aspect: Aspect ratio — '9:16' or '16:9'. + model: Model to use (e.g. 'omni-flash', 'veo-lite'). If omitted, Flow's default applies. + duration: Clip length in seconds (4, 6, 8, or 10). + count: Number of videos to generate (1-4). initial_frame: Path to start frame image (required for i2v). end_frame: Path to end frame image (optional for i2v). reference_images: List of reference image paths (ingredients) for r2v. @@ -740,6 +835,9 @@ async def gflow_generate_video( prompt=prompt[:80], mode=mode, aspect=aspect, + model=model, + duration=duration, + count=count, profile=resolved_profile, ) @@ -748,11 +846,22 @@ async def gflow_generate_video( return adapted tool_specs = adapted + # Protect against omni-flash silently dropping i2v frames (issue #125) + # by enforcing the same I2V_DEFAULT_MODEL logic as the CLI. + if mode == "i2v" and model is None: + from gflow_cli.api.video import I2V_DEFAULT_MODEL + model = I2V_DEFAULT_MODEL.value + payload: dict[str, Any] = { "prompt": prompt, "mode": mode, "aspect": aspect, + "count": count, } + if model is not None: + payload["model"] = model + if duration is not None: + payload["duration"] = duration media, media_err = _build_video_media_inputs( mode=mode, diff --git a/src/gflow_cli/worker/daemon.py b/src/gflow_cli/worker/daemon.py index a86359e0d..8aef53644 100644 --- a/src/gflow_cli/worker/daemon.py +++ b/src/gflow_cli/worker/daemon.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import contextlib from pathlib import Path from typing import Any @@ -73,7 +74,7 @@ async def start(self) -> None: ) await asyncio.sleep(5) - async def process_task(self, task: QueueTask) -> None: + async def process_task(self, task: QueueTask, client: FlowApiClient | None = None) -> None: logger.info( "Processing task", task_id=task.task_id, @@ -100,31 +101,35 @@ async def process_task(self, task: QueueTask) -> None: DataRepository(self.db), prompt_mode=settings.history_prompts ) try: - async with FlowApiClient( - profile_dir=profile_dir, - headless=headless, - transport=transport, - out_dir=out_dir, - # Reuse the cached settings: a bare Settings() in the client - # would re-read .env files live per task and could disagree - # with the task parameters derived from get_settings(). - settings=settings, - ) as client: + client_ctx = ( + contextlib.nullcontext(client) + if client is not None + else FlowApiClient( + profile_dir=profile_dir, + headless=headless, + transport=transport, + out_dir=out_dir, + settings=settings, + ) + ) + async with client_ctx as active_client: project_title = task.payload.get("project_title", "gflow-cli images") project_created = False if project_id: project_flow_id = project_id project = ProjectInfo(project_id=project_id, title=project_title) else: - project = await client.create_project(title=project_title) + project = await active_client.create_project(title=project_title) project_flow_id = project.project_id project_created = True if count == 1: - img = await client.generate_image(project_id=project_flow_id, req=req) + img = await active_client.generate_image( + project_id=project_flow_id, req=req + ) images = [img] else: - images = await client.generate_images_batch( + images = await active_client.generate_images_batch( project_id=project_flow_id, req=req, count=count, @@ -137,7 +142,7 @@ async def process_task(self, task: QueueTask) -> None: target = image_output_path( settings.output_dir, job_id=img.media_name, index=i ) - saved = await client.download_image(img, target) + saved = await active_client.download_image(img, target) saved_paths.append(saved) recorder.record_generated_images( @@ -175,13 +180,18 @@ async def process_task(self, task: QueueTask) -> None: DataRepository(self.db), prompt_mode=settings.history_prompts ) try: - async with FlowApiClient( - profile_dir=profile_dir, - headless=headless, - transport=transport, - out_dir=out_dir, - settings=settings, # same rationale as the image path above - ) as client: + client_ctx = ( + contextlib.nullcontext(client) + if client is not None + else FlowApiClient( + profile_dir=profile_dir, + headless=headless, + transport=transport, + out_dir=out_dir, + settings=settings, + ) + ) + async with client_ctx as active_client: def on_started(started: VideoStarted) -> None: try: @@ -194,7 +204,7 @@ def on_started(started: VideoStarted) -> None: except Exception as exc: logger.warning("Failed to record started video", exc_info=exc) - result = await client.generate_video( + result = await active_client.generate_video( req=req, project_id=project_id, out_dir=out_dir, @@ -292,11 +302,8 @@ def _build_image_request(self, payload: dict[str, Any]) -> GenerateImageRequest: model = ImageModel.from_cli(model_val) if model_val else ImageModel.NARWHAL refs = tuple(ImageRef(r) for r in payload.get("refs", [])) + ref_names = tuple(payload.get("ref_names", [])) ref_paths = tuple(Path(p) for p in payload.get("ref_paths", [])) - # NOTE: the payload may carry "ref_names" (the MCP layer resolves them - # for the video request); the image transport attaches remote refs by - # media id, so GenerateImageRequest has no ref_names field and must - # not receive one. reference_entities = tuple(payload.get("reference_entities", [])) reference_entity_names = tuple(payload.get("reference_entity_names", [])) count = payload.get("count", 1) @@ -306,6 +313,7 @@ def _build_image_request(self, payload: dict[str, Any]) -> GenerateImageRequest: aspect=aspect, model=model, refs=refs, + ref_names=ref_names, ref_paths=ref_paths, reference_entities=reference_entities, reference_entity_names=reference_entity_names, diff --git a/tests/api/transports/test_ui_automation.py b/tests/api/transports/test_ui_automation.py index 28366d496..9120f2cf4 100644 --- a/tests/api/transports/test_ui_automation.py +++ b/tests/api/transports/test_ui_automation.py @@ -32,6 +32,7 @@ ONBOARDING_SELECTORS, SUBMIT_BUTTON_SELECTORS, UiAutomationTransport, + _collect_images_from_body, _count_tabs_locator, # noqa: PLC2701 _summarize_batch_request_body, # noqa: PLC2701 ) @@ -757,15 +758,24 @@ class TestSendPrompt: async def test_types_prompt_and_clicks_submit(self) -> None: t = UiAutomationTransport() page = _make_prompt_page(input_visible=True, submit_visible=True) - await t._send_prompt(page, "hello world") # type: ignore[attr-defined] + await t._send_prompt(page, "hello") # type: ignore[attr-defined] page._input_loc.click.assert_called_once() # type: ignore[attr-defined] - # Clear (Ctrl+A + Delete) then insert_text (single beforeinput event — near-instant). + # Clear (Ctrl+A + Delete) then insert_text. press_calls = [c.args[0] for c in page.keyboard.press.call_args_list] assert "Control+A" in press_calls assert "Delete" in press_calls - page.keyboard.insert_text.assert_called_once_with("hello world") + + insert_calls = [c.args[0] for c in page.keyboard.insert_text.call_args_list] + assert insert_calls == ["h", "e", "l", "l", "o"] page._submit_loc.click.assert_called_once() # type: ignore[attr-defined] + @pytest.mark.asyncio + async def test_fast_typing_submits_at_once(self) -> None: + t = UiAutomationTransport() + page = _make_prompt_page(input_visible=True, submit_visible=True) + await t._send_prompt(page, "hello", fast=True) # type: ignore[attr-defined] + page.keyboard.insert_text.assert_called_once_with("hello") + @pytest.mark.asyncio async def test_falls_back_to_enter_when_no_submit_button(self) -> None: t = UiAutomationTransport() @@ -1109,7 +1119,10 @@ async def test_generate_images_threads_prefer_classic( async def test_non_200_response_raises(self) -> None: t = UiAutomationTransport() t._setup_done = True # type: ignore[attr-defined] - t._page = MagicMock() # type: ignore[attr-defined] + page_mock = MagicMock() + page_mock.goto = AsyncMock() + page_mock.wait_for_timeout = AsyncMock() + t._page = page_mock # type: ignore[attr-defined] with ( patch.object(t, "_enter_editor", new=AsyncMock()), @@ -1131,6 +1144,8 @@ async def test_non_200_response_raises(self) -> None: ): await t.generate_images(project_id="x", request=_req()) + page_mock.goto.assert_awaited_with(page_mock.url, wait_until="domcontentloaded") + @pytest.mark.asyncio async def test_200_with_no_parseable_media_raises(self) -> None: t = UiAutomationTransport() @@ -2933,3 +2948,37 @@ async def _probe(_p: object, _label: str, _sels: object) -> MagicMock: await UiAutomationTransport._switch_to_video_mode(page, out_dir=None) assert order and order[0] == "exit_agent", f"expected exit_agent first, got {order}" + + + +class TestCollectImagesFromBody: + def test_extracts_display_name_from_fully_qualified_workflows(self) -> None: + """Verifies that _collect_images_from_body strips path prefixes for display names.""" + body = { + "media": [ + { + "name": "projects/proj-uuid/assets/asset-001", + "workflowId": "wf-001", + "image": { + "generatedImage": { + "seed": 42, + "prompt": "test", + "modelNameType": "NARWHAL", + "aspectRatio": "IMAGE_ASPECT_RATIO_PORTRAIT", + "fifeUrl": "url", + }, + "dimensions": {"width": 1024, "height": 1024}, + }, + } + ], + "workflows": [ + { + "name": "projects/proj-uuid/workflows/wf-001", + "metadata": {"displayName": "My Cool Prompt"} + } + ], + } + images = [] + _collect_images_from_body(body, images) + assert len(images) == 1 + assert images[0].display_name == "My Cool Prompt" diff --git a/tests/api/transports/test_ui_automation_video.py b/tests/api/transports/test_ui_automation_video.py index 6ebf15ae4..68bd52806 100644 --- a/tests/api/transports/test_ui_automation_video.py +++ b/tests/api/transports/test_ui_automation_video.py @@ -1072,17 +1072,17 @@ def test_context_include_text_tier_covers_pt_ru_en_menu_scoped(self) -> None: f"unscoped text segment: {segment.strip()!r}" ) - def test_include_button_is_a_two_tier_cascade(self) -> None: + def test_include_button_is_a_four_tier_cascade(self) -> None: assert isinstance(PICKER_INCLUDE_BUTTON, tuple) - assert len(PICKER_INCLUDE_BUTTON) == 2 + assert len(PICKER_INCLUDE_BUTTON) == 4 def test_include_button_text_tier_covers_pt_ru_en(self) -> None: - text_tier = PICKER_INCLUDE_BUTTON[0] + text_tier = PICKER_INCLUDE_BUTTON[2] for caption in ("Incluir no comando", "Добавить в запрос", "Add to prompt"): assert caption in text_tier, f"missing caption {caption!r}" def test_include_button_structural_tier_is_lone_iconless_dialog_button(self) -> None: - structural = PICKER_INCLUDE_BUTTON[1] + structural = PICKER_INCLUDE_BUTTON[0] assert structural.startswith("[role='dialog'][data-state='open']") assert ":not(:has(i.google-symbols))" in structural @@ -1221,7 +1221,7 @@ async def test_attach_audio_logs_selector_tier( if e["event"] == "ui_automation_video.include_selector_tier" ] assert tier_events, "expected an include_selector_tier event" - assert tier_events[0]["tier"] == "text" + assert tier_events[0]["tier"] == "structural_overlay_open" assert tier_events[0]["surface"] == "vozes_button" @pytest.mark.asyncio @@ -1323,10 +1323,7 @@ def test_apostrophe_name_does_not_go_into_a_quoted_css_selector(self) -> None: VideoGenerationMixin._remote_option_tile(page, "Wren's cabin") # role-based match: the raw name is passed as the accessible name, # never interpolated into a `:has-text('...')` CSS string. - page.get_by_role.assert_called_once() - args, kwargs = page.get_by_role.call_args - assert args[0] == "option" - assert kwargs.get("name") == "Wren's cabin" + page.get_by_text.assert_called_once_with("Wren's cabin", exact=True) page.locator.assert_not_called() def test_matches_exactly_so_a_substring_name_cannot_attach_the_wrong_tile(self) -> None: @@ -1335,8 +1332,7 @@ def test_matches_exactly_so_a_substring_name_cannot_attach_the_wrong_tile(self) # wrong image silently. page = MagicMock() VideoGenerationMixin._remote_option_tile(page, "cabin") - _, kwargs = page.get_by_role.call_args - assert kwargs.get("exact") is True + page.get_by_text.assert_called_once_with("cabin", exact=True) class TestRemoteReferencesDialogGuard: @@ -1350,6 +1346,7 @@ def _locator_mock() -> MagicMock: loc.wait_for = AsyncMock() loc.click = AsyncMock() loc.press_sequentially = AsyncMock() + loc.is_visible = AsyncMock(return_value=True) loc.first = loc loc.last = loc return loc diff --git a/tests/api/transports/test_ui_character_editor.py b/tests/api/transports/test_ui_character_editor.py index 04775ffb6..4debd4368 100644 --- a/tests/api/transports/test_ui_character_editor.py +++ b/tests/api/transports/test_ui_character_editor.py @@ -835,14 +835,15 @@ async def test_submits_self_contained_prompt_replacing_prefill(self) -> None: await t._submit_body_prompt(page, "red raincoat, rubber boots") # type: ignore[attr-defined] expected = _BODY_TRIPTYCH_PREAMBLE + "red raincoat, rubber boots" - assert inserted == [expected], ( + inserted_str = "".join(inserted) + assert inserted_str == expected, ( f"submitted text must be the self-contained triptych prompt + " - f"description, independent of the pre-fill; got {inserted}" + f"description, independent of the pre-fill; got {inserted_str}" ) # None of Flow's pre-filled template leaked through. - assert "Tríptico" not in inserted[0] - assert "[DESCREVA O CORPO E A ROUPA]" not in inserted[0] - assert "red raincoat, rubber boots" in inserted[0] + assert "Tríptico" not in inserted_str + assert "[DESCREVA O CORPO E A ROUPA]" not in inserted_str + assert "red raincoat, rubber boots" in inserted_str @pytest.mark.asyncio async def test_triptych_instruction_always_present(self) -> None: @@ -857,7 +858,7 @@ async def test_triptych_instruction_always_present(self) -> None: await t._submit_body_prompt(page, "warrior outfit") # type: ignore[attr-defined] - submitted = inserted[0] + submitted = "".join(inserted) assert "front, side (3/4), and back" in submitted, ( f"front/side/back triptych instruction must always be present; got {submitted!r}" ) diff --git a/tests/mcp/test_server.py b/tests/mcp/test_server.py index a93a42a92..93be40134 100644 --- a/tests/mcp/test_server.py +++ b/tests/mcp/test_server.py @@ -429,7 +429,7 @@ def test_video_t2v_params_mirrored(self, mcp_server: Any) -> None: """Key parameters of `gflow video t2v` must appear in gflow_generate_video.""" tool = mcp_server._tool_manager._tools["gflow_generate_video"] schema_props = set(tool.parameters.get("properties", {}).keys()) - required_in_both = {"prompt", "mode", "aspect", "profile"} + required_in_both = {"prompt", "mode", "aspect", "profile", "model", "duration", "count"} assert required_in_both.issubset(schema_props), ( f"MCP tool missing CLI params: {required_in_both - schema_props}" ) diff --git a/tests/mcp/test_tools_helpers.py b/tests/mcp/test_tools_helpers.py index 041de7f90..8b297f61b 100644 --- a/tests/mcp/test_tools_helpers.py +++ b/tests/mcp/test_tools_helpers.py @@ -233,9 +233,10 @@ def test_resolve_ref_name_found_asset_without_name_errors_not_uuid_passthrough() def test_resolve_payload_ref_names_leaves_image_refs_untouched() -> None: - """PR #245 review #1: _resolve_payload_ref_names must only be applied to the - video path. Image 'refs' (media-id UUIDs) attach by id and must pass through - even when absent from the local catalog.""" + """PR #245 review #1 (updated): image 'refs' (media-id UUIDs) must pass through + even when absent from the local catalog without erroring. The UI automation + transport will attempt to resolve them if found, but experimental REST transports + can still attempt to use the raw UUID.""" from gflow_cli.mcp.tools import _resolve_payload_ref_names repo = _FakeRepo(asset=None) # UUID not in local catalog @@ -249,3 +250,17 @@ def test_resolve_payload_ref_names_leaves_image_refs_untouched() -> None: assert err_image is None assert "ref_names" not in p_image assert p_image["refs"] == [_A_UUID] + + +def test_resolve_payload_ref_names_populates_ref_names_for_found_image_refs() -> None: + from gflow_cli.mcp.tools import _resolve_payload_ref_names + + repo = _FakeRepo(asset=_Asset({"display_name": "My Found Image"})) + payload = {"refs": [_A_UUID]} + + err = _resolve_payload_ref_names(repo, "default", payload, task_type="i2i") + + assert err is None + assert "ref_names" in payload + assert payload["ref_names"] == ["My Found Image"] + assert payload["refs"] == [_A_UUID] diff --git a/tests/mcp/test_tools_wired.py b/tests/mcp/test_tools_wired.py index 0f5bf5bee..41b479f39 100644 --- a/tests/mcp/test_tools_wired.py +++ b/tests/mcp/test_tools_wired.py @@ -51,6 +51,7 @@ class _FakeImage: aspect_ratio: str = "1:1" seed: int = 12345 fife_url: str = "http://fake" + display_name: str | None = None def _completed_video_result(media_id: str = "media-vid-wired") -> VideoResult: @@ -101,7 +102,7 @@ async def test_image_t2i_returns_completed(self, temp_db: DataStore, tmp_path: P return_value="default", ), patch( - "gflow_cli.worker.daemon.FlowApiClient", + "gflow_cli.mcp.tools.FlowApiClient", return_value=_FakeFlowApiClient(), ), patch( @@ -143,7 +144,7 @@ async def test_image_completed_task_has_params( return_value="default", ), patch( - "gflow_cli.worker.daemon.FlowApiClient", + "gflow_cli.mcp.tools.FlowApiClient", return_value=_FakeFlowApiClient(), ), patch( @@ -208,7 +209,7 @@ async def test_video_t2v_returns_completed(self, temp_db: DataStore, tmp_path: P return_value="default", ), patch( - "gflow_cli.worker.daemon.FlowApiClient", + "gflow_cli.mcp.tools.FlowApiClient", return_value=_FakeFlowApiClient(), ), patch( @@ -295,7 +296,7 @@ async def test_video_failed_task_returns_error( return_value="default", ), patch( - "gflow_cli.worker.daemon.FlowApiClient", + "gflow_cli.mcp.tools.FlowApiClient", return_value=failing_client, ), patch( @@ -383,7 +384,7 @@ async def test_task_enqueued_and_completed(self, temp_db: DataStore, tmp_path: P with ( patch( - "gflow_cli.worker.daemon.FlowApiClient", + "gflow_cli.mcp.tools.FlowApiClient", return_value=_FakeFlowApiClient(), ), patch( @@ -423,7 +424,7 @@ async def test_envelope_returns_media_id_not_workflow_id( with ( patch( - "gflow_cli.worker.daemon.FlowApiClient", + "gflow_cli.mcp.tools.FlowApiClient", return_value=_FakeFlowApiClient(), ), patch( diff --git a/uv.lock b/uv.lock index a27be0cd8..7e3374ded 100644 --- a/uv.lock +++ b/uv.lock @@ -189,6 +189,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "apify-fingerprint-datapoints" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/f1/b74f95767581372ab849c8b13e384b62f60d034584892c60c4a3442d9312/apify_fingerprint_datapoints-0.13.0.tar.gz", hash = "sha256:263141c19e9bc90a821e6b4e2b845925f17e0b8fbd53a897fc71546bd50df7f1", size = 934827, upload-time = "2026-05-04T09:08:45.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/58/8402442bf6af5a3a8068fe5431c42ea4f73c1eb18f621f9bf7c5de80caf5/apify_fingerprint_datapoints-0.13.0-py3-none-any.whl", hash = "sha256:0213d42297be19e8035202b41fb2e840a1e5d79874c99c882a5027a7d0b1a0eb", size = 761652, upload-time = "2026-05-04T09:08:43.347Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -251,6 +260,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/57/2a716f4ecf6c50b2dbe27439507c480bb7ca5725edef82349ecdcfcdd084/browser_cookie3-0.20.1-py3-none-any.whl", hash = "sha256:4b38bf669d386250733c8339f0036e1cf09c3d8e4d326fd507b9afb84def13d6", size = 17229, upload-time = "2025-01-04T14:46:14.753Z" }, ] +[[package]] +name = "browserforge" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apify-fingerprint-datapoints" }, + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/6f/8975af88d203efd70cc69477ebac702babef38201d04621c9583f2508f25/browserforge-1.2.4.tar.gz", hash = "sha256:05686473793769856ebd3528c69071f5be0e511260993e8b2ba839863711a0c4", size = 36700, upload-time = "2026-02-03T02:52:09.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/35/ce962f738ae28ffce6293e7607b129075633e6bb185a5ab87e49246eedc2/browserforge-1.2.4-py3-none-any.whl", hash = "sha256:fb1c14e62ac09de221dcfc73074200269f697596c642cb200ceaab1127a17542", size = 37890, upload-time = "2026-02-03T02:52:08.745Z" }, +] + +[[package]] +name = "camoufox" +version = "0.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "browserforge" }, + { name = "click" }, + { name = "language-tags" }, + { name = "lxml" }, + { name = "numpy" }, + { name = "orjson" }, + { name = "platformdirs" }, + { name = "playwright" }, + { name = "pysocks" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "screeninfo" }, + { name = "tqdm" }, + { name = "typing-extensions" }, + { name = "ua-parser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/15/e0a1b586e354ea6b8d6612717bf4372aaaa6753444d5d006caf0bb116466/camoufox-0.4.11.tar.gz", hash = "sha256:0a2c9d24ac5070c104e7c2b125c0a3937f70efa416084ef88afe94c32a72eebe", size = 64409, upload-time = "2025-01-29T09:33:20.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/7b/a2f099a5afb9660271b3f20f6056ba679e7ab4eba42682266a65d5730f7e/camoufox-0.4.11-py3-none-any.whl", hash = "sha256:83864d434d159a7566990aa6524429a8d1a859cbf84d2f64ef4a9f29e7d2e5ff", size = 71628, upload-time = "2025-01-29T09:33:18.558Z" }, +] + [[package]] name = "certifi" version = "2026.4.22" @@ -600,6 +648,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, ] +[[package]] +name = "cython" +version = "3.2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/6b/80101e02ebacaf9232ecf32bf6a788d36b27d820ee02434746252569ef98/cython-3.2.8.tar.gz", hash = "sha256:f4f23a56b25221a06f91817fe8f3114ab8b48a4fac73187dbb64bc2c4a87961f", size = 3290300, upload-time = "2026-06-30T07:41:57.874Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/44/379c4f16d6b9e920ca8d00dc3bb96ea3f4fb8b73ed8b99c136a7667e2605/cython-3.2.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a7761e1a97081b707a9ea4fb7c8ecdeddcc6b5ae00dda0f0b7bbf933c1b599f9", size = 2977552, upload-time = "2026-06-30T07:42:15.673Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c4/47e0bcfc15b36b1c5cbde5235c60bf88df552ab216ddb836d7f816386ae6/cython-3.2.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f2547a31fbd3b1610a8859a16edee2a141f7781691cb98a2c6fd54870c5f7541", size = 2995546, upload-time = "2026-06-30T07:42:23.618Z" }, + { url = "https://files.pythonhosted.org/packages/dc/31/9487462239ccd47feb70fc023b2f416cee6cf30fe79d15f6a1eda59ea107/cython-3.2.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0d78205f525287de94effa2f2fab6b9edafdab44ef8c58ecf52fcb1013225d68", size = 2984097, upload-time = "2026-06-30T07:42:31.335Z" }, + { url = "https://files.pythonhosted.org/packages/ab/46/b491e2eebf00864421fe7b4c2c7d3c2fdd12d16ef8b76c42abd4e571d86b/cython-3.2.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c7f4143d0f9fb7b26444e00ebf7c8845f585d56f37b73bd3fb3dac269af8732", size = 3012195, upload-time = "2026-06-30T07:42:39.299Z" }, + { url = "https://files.pythonhosted.org/packages/92/a2/0f2eaa5076bcaef52567471a54ce02ffd70007bf8688cd054f7aab9bc3b8/cython-3.2.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:127bc4039be48c6eebe7f1d68c33d23eb3a0c5ae95e1d730bdd048837751438b", size = 2892527, upload-time = "2026-06-30T07:42:55.45Z" }, + { url = "https://files.pythonhosted.org/packages/c4/19/31aa63ab719b2e1eea5f200a8a54e2591dc1966a6b75e3de30ef8be9bc2c/cython-3.2.8-py3-none-any.whl", hash = "sha256:f635e113677666de13a2ec2979e9b1d5b90617cdfd1a691d3559be81e2dd6cb9", size = 1258688, upload-time = "2026-06-30T07:41:55.624Z" }, +] + [[package]] name = "decorator" version = "5.3.1" @@ -806,6 +868,9 @@ dependencies = [ ] [package.optional-dependencies] +camoufox = [ + { name = "camoufox" }, +] chain = [ { name = "av" }, ] @@ -864,6 +929,7 @@ requires-dist = [ { name = "av", marker = "extra == 'chain'", specifier = ">=12" }, { name = "av", marker = "extra == 'dev'", specifier = ">=12" }, { name = "browser-cookie3", specifier = ">=0.20.1" }, + { name = "camoufox", marker = "extra == 'camoufox'" }, { name = "click", specifier = ">=8.1.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "gcsfs", marker = "extra == 'gcs'", specifier = ">=2024.2.0" }, @@ -887,7 +953,7 @@ requires-dist = [ { name = "universal-pathlib", marker = "extra == 's3'", specifier = ">=0.2.5" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, ] -provides-extras = ["dev", "gcs", "s3", "chain", "patchright"] +provides-extras = ["dev", "gcs", "s3", "chain", "patchright", "camoufox"] [package.metadata.requires-dev] containers = [ @@ -1370,6 +1436,117 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "language-tags" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/d1/b37165bed9016c19e2cdecf4176fbd902a014fb8a87dd44415438e190969/language_tags-1.3.1.tar.gz", hash = "sha256:b15f05505dad3ad296a1782d5a6083fd141309186094d1ab08095348f4203f37", size = 222642, upload-time = "2026-05-08T11:46:24.418Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/21/b48b9e8408b3faec9fb7cb2f68352c7724add35a980c948eaceb29ac41e4/language_tags-1.3.1-py3-none-any.whl", hash = "sha256:f7db7ef8879523019603e09644a86d95ba60595ce5e5ea82d46e8971b0f68f7b", size = 216654, upload-time = "2026-05-08T11:46:17.836Z" }, +] + +[[package]] +name = "lxml" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461, upload-time = "2026-05-18T19:17:25.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375, upload-time = "2026-05-18T19:17:34.555Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654, upload-time = "2026-05-18T19:17:42.917Z" }, + { url = "https://files.pythonhosted.org/packages/4c/77/1bc7eeb0de4577d783fb625aa092cc9357883bba35845a3666bf1259f3dc/lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a", size = 5067921, upload-time = "2026-05-18T19:17:49.175Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d", size = 5002456, upload-time = "2026-05-18T19:17:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8", size = 5202776, upload-time = "2026-05-18T19:18:08.924Z" }, + { url = "https://files.pythonhosted.org/packages/7a/45/689824ffb237fd10125ad273f32b28ff04dc6203c2822c85ff65a93df65e/lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009", size = 5329945, upload-time = "2026-05-18T19:18:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c0/ef73af53767e958fd87d437c170f272e2f6e6c0f854939f133a895f1e711/lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6", size = 4659237, upload-time = "2026-05-18T19:18:18.657Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/e1158e40397585e91cb0472374a1f63d0926a1ddeaa92f13d1a1ffe306d5/lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8", size = 5265904, upload-time = "2026-05-18T19:18:24.883Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/8687e5d1400ed1c0bc41dace232ebb7553952b618ea1f2e5fb6e2cfbbe23/lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83", size = 5045225, upload-time = "2026-05-18T19:17:20.073Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/d877bd1ae2e5ffdfd4836565aba350db31feb2f2656d6ce70316ed66a05e/lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6", size = 4712721, upload-time = "2026-05-18T19:17:40.512Z" }, + { url = "https://files.pythonhosted.org/packages/44/4d/1f44fd1d770b10dacbf6b5c6e520f4d6e0708744930f719dc04e67cab981/lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c", size = 5252549, upload-time = "2026-05-18T19:17:51.236Z" }, + { url = "https://files.pythonhosted.org/packages/64/5d/1d66b84f850089254c230ef6ea6b267a5a54e2e179a5d960036a05d501d7/lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08", size = 5226877, upload-time = "2026-05-18T19:18:00.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/00/84c4b5302d42a2d0184f38d538c8a197f33b52a50bd4f7bcfe990bce3036/lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621", size = 3594072, upload-time = "2026-05-18T19:17:12.714Z" }, + { url = "https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28", size = 4025469, upload-time = "2026-05-18T19:17:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/570e6390e4110331e6208b2ba83d1482cc9146808ee118b22824a34c1070/lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b", size = 3667640, upload-time = "2026-05-19T19:22:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" }, + { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" }, + { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" }, + { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" }, + { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" }, + { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" }, + { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" }, + { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" }, + { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" }, + { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, + { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" }, + { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" }, + { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" }, + { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" }, + { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" }, + { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, + { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, + { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, + { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" }, + { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" }, + { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" }, + { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" }, + { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/b5/32/86a3f0f724a3a402d4627937a7fc27b160e45e7012b4adf47f6e1e844511/lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e", size = 3930127, upload-time = "2026-05-18T19:19:02.27Z" }, + { url = "https://files.pythonhosted.org/packages/40/44/d832e82af08723761556d004b1d04d281c09f9a8cecd7d3148548c9941a3/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004", size = 4210769, upload-time = "2026-05-18T19:20:41.427Z" }, + { url = "https://files.pythonhosted.org/packages/6d/39/0dc5949f759ed7d951e0bb8c2f2d9d7aca1908d22352fa84a8afd2ea54af/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e", size = 4318163, upload-time = "2026-05-18T19:20:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fb/8ab3845fe046ba4cbf74536bcf6801a774b7caf4350de1c5d37f1f0a9e90/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2", size = 4250945, upload-time = "2026-05-18T19:20:47.385Z" }, + { url = "https://files.pythonhosted.org/packages/68/1b/7553ab136894374ffae8851ec06f98f511cd8e66246e41b6be059d0a7289/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf", size = 4401664, upload-time = "2026-05-18T19:20:50.489Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989, upload-time = "2026-05-18T19:18:38.158Z" }, +] + [[package]] name = "lz4" version = "4.4.5" @@ -1764,6 +1941,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, ] +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, + { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, + { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -2311,6 +2556,41 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pyobjc-core" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/87/16564ef5e4568ee0edd9e712d8111dc8b67621d6bb6ff430646ee2d637dd/pyobjc_core-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24b76a63caf0b5369d4a377c7c0438cd70df81539057af3db839bfaa3579e04a", size = 6484662, upload-time = "2026-06-19T16:04:44.979Z" }, + { url = "https://files.pythonhosted.org/packages/8c/88/300ad283bed0c971c52dcac6f70113e138169d4ce6d856ddd03d16081e51/pyobjc_core-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b", size = 6433347, upload-time = "2026-06-19T16:04:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2", size = 6436004, upload-time = "2026-06-19T16:04:53.257Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/bd309ede07784c6e5fac4b440c90a5f72a66da7859ed303a9392fe8a5f3f/pyobjc_core-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:efe465e3ecc6fc73f7c7622620345d134a8d34564ab1c29d8247e45f4ed55071", size = 6687044, upload-time = "2026-06-19T16:04:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8a/cfa4f56939d554dbb342ec6e5226a441e2f552bc2002a0ddf7705bb11bef/pyobjc_core-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2b8fc0531c27277325e113ac00b8a72a82e6145f0a88175b9425d8de814ff69a", size = 6429289, upload-time = "2026-06-19T16:05:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/74/446c89bc18103aaa4a00d1fb85ff8acace9a0dc3f362d9678ebf7571e275/pyobjc_core-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bef500f979e22d54f9da3aaebf6a48f873234b324858bd69256055a318955c7", size = 6690181, upload-time = "2026-06-19T16:05:06.201Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/0121ee4c616af07ad2de8cd1a286f6978dc9a227eb58b7c2e875cb68a1df/pyobjc_core-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:047c226eeb58a2993ace5e8904e71cc9426ee20d064c617f8fbf32717d37093e", size = 6487078, upload-time = "2026-06-19T16:05:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a8/cb9fcc150f97d0bf22a2028f88b24cc35949beb1bcc7b8bc5c17d4401677/pyobjc_core-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1188613805336270279570467e4455b74cb6c0f60913ac74c917ee1c37cfaecb", size = 6733064, upload-time = "2026-06-19T16:05:14.313Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/d6/dc66ea8519a0475efbccf73f82cc28066339bb300a27f5e1bf91ab1d7002/pyobjc_framework_cocoa-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dc6da84f4fc62cc25463bbb85e77a57b8d5ac6caf9a60702daf2edb601332f15", size = 387298, upload-time = "2026-06-19T16:07:37.412Z" }, + { url = "https://files.pythonhosted.org/packages/f7/cf/1b3b32b2f28f66cc053c3438ef4e6df36a1591945bf05e7399da18d74553/pyobjc_framework_cocoa-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080", size = 388113, upload-time = "2026-06-19T16:07:38.9Z" }, + { url = "https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f", size = 388183, upload-time = "2026-06-19T16:07:40.483Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f3/dfc9af4c9eb2e5389c860ad5ef252be9fe456db09f39d537555dc5057aa1/pyobjc_framework_cocoa-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:dc2eaca2f13c7bcd8e41e51a372e47825dea9dd3126108760eed7ba883d2945c", size = 392275, upload-time = "2026-06-19T16:07:42.078Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c8/b90baa8f3592eded79b4be98fb59d2b8dc16b62361e34292bd95806ebd9f/pyobjc_framework_cocoa-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b386c324d64ae565c1f6b7dfb77be68f640a1c7c23caa6966ab661131f519561", size = 388357, upload-time = "2026-06-19T16:07:43.364Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/64a94651b9294702d55e748d94de30e25bc59d0784526be7643f4467eccd/pyobjc_framework_cocoa-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a6c584e2af0813cb2f6103b184e632665a26f58c1bd5b08ffd6e95a19c617f7b", size = 392404, upload-time = "2026-06-19T16:07:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/26e8a7bf1f5e8caa38b7f80d486296f9fd3c97e71ad7e5444ef22e802758/pyobjc_framework_cocoa-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b6023657b8d6cc049a21bd6b4752425f2f53c42f9f0b02d64c7608cc484bf103", size = 388589, upload-time = "2026-06-19T16:07:46.276Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eedf743a303ea742b8e082afe3613fb4d6618bc1a48cf2568b004ce906f7/pyobjc_framework_cocoa-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c685ccd8e266a07cf912a2c5a13b1f2eff2a868a1aff163b4801b4687bd425e1", size = 392691, upload-time = "2026-06-19T16:07:47.477Z" }, +] + [[package]] name = "pyright" version = "1.1.409" @@ -2324,6 +2604,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" }, ] +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -2720,6 +3009,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/52/5ccdc01f7a8a61357d15a66b5d8a6580aa8529cb33f32e6cbb71c52622c5/s3fs-2026.3.0-py3-none-any.whl", hash = "sha256:2fa40a64c03003cfa5ae0e352788d97aa78ae8f9e25ea98b28ce9d21ba10c1b8", size = 32399, upload-time = "2026-03-27T19:28:19.702Z" }, ] +[[package]] +name = "screeninfo" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cython", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/bb/e69e5e628d43f118e0af4fc063c20058faa8635c95a1296764acc8167e27/screeninfo-0.8.1.tar.gz", hash = "sha256:9983076bcc7e34402a1a9e4d7dabf3729411fd2abb3f3b4be7eba73519cd2ed1", size = 10666, upload-time = "2022-09-09T11:35:23.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/bf/c5205d480307bef660e56544b9e3d7ff687da776abb30c9cb3f330887570/screeninfo-0.8.1-py3-none-any.whl", hash = "sha256:e97d6b173856edcfa3bd282f81deb528188aff14b11ec3e195584e7641be733c", size = 12907, upload-time = "2022-09-09T11:35:21.351Z" }, +] + [[package]] name = "shadowcopy" version = "0.0.4" @@ -2855,6 +3157,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "tqdm" +version = "4.68.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -2876,6 +3190,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "ua-parser" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ua-parser-builtins" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/98/5e4b52d772a048af122a6fc5ce365c311efb9f5e79c55fd4fdd7c9f59e83/ua_parser-1.0.2.tar.gz", hash = "sha256:bab404ad42fb37f943107da2f6003ffc79724d11cc95076a7a539513371779da", size = 33239, upload-time = "2026-04-05T20:14:28.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/7c/6367995ff57aaa2d9e1055adbaec2519cf5a979780a83a93fdf8c6ec37be/ua_parser-1.0.2-py3-none-any.whl", hash = "sha256:0f8e6d0484af2a9ff804bba5a4fe696e87c028eaba98ad9a7dfae873fef7788a", size = 31219, upload-time = "2026-04-05T20:14:26.913Z" }, +] + +[[package]] +name = "ua-parser-builtins" +version = "202606" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a3/f26edd95a2ce2ffbf559da432e5ff544e8dcda3673d1c775f71afffb18b7/ua_parser_builtins-202606-py3-none-any.whl", hash = "sha256:13b483eb12a5419c1094ce02b7df705fefc6b5d869764b3ffbf6c940c6d014cb", size = 90676, upload-time = "2026-06-01T22:40:53.008Z" }, +] + [[package]] name = "universal-pathlib" version = "0.3.10"