From f007973a3f09b065539df21ce0d7896c1cd53720 Mon Sep 17 00:00:00 2001 From: Rohit Agrawal Date: Mon, 24 Aug 2026 10:34:40 -0400 Subject: [PATCH] Improve managed config authoring and local testing --- README.md | 31 ++- pyproject.toml | 3 + src/ucode/cli.py | 218 +++++++++++----- src/ucode/managed_config.py | 213 ++++++++++++--- src/ucode/managed_resolve.py | 9 +- src/ucode/managed_setup.py | 7 +- src/ucode/managed_wizard.py | 210 +++++++-------- src/ucode/ui.py | 464 ++++++++++++++++++++++++++++++--- tests/test_cli.py | 264 +++++++++++++++---- tests/test_managed_config.py | 172 ++++++++++-- tests/test_managed_wizard.py | 119 ++++++++- tests/test_managed_workflow.py | 133 ++++++++++ tests/test_ui.py | 226 ++++++++++++++++ uv.lock | 336 +++++++++++++----------- 14 files changed, 1901 insertions(+), 504 deletions(-) create mode 100644 tests/test_managed_workflow.py diff --git a/README.md b/README.md index f26ea6ba..00186ced 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,9 @@ ucode setup # agents and models (start here) ucode setup mcps # managed MCP servers ucode setup skills # managed skills ucode setup spend-tiers # spend-based routing -ucode apply # publish it to the workspace +ucode claude --local # optionally test the local draft +ucode codex --local # edit and test as often as needed +ucode apply # publish the latest draft to the workspace ``` `ucode setup` walks through the agents to enable and which one bare `ucode` launches, then per agent: @@ -238,10 +240,10 @@ that switches the default agent and model as the workspace burns through a budge command also offers to publish right away, so you can apply changes incrementally; answering the section prompts also runs the matching `ucode configure` step, which does configure this machine. -Everything is written to `~/.ucode/managed-state.json` — the one local managed-config file — which -`ucode apply` publishes. Re-running `ucode setup` keeps the MCP servers, skills, tracing table, and -tiered spend policy already authored, rather than clearing them; to drop one, edit the file and reload -it with `ucode setup --from-file`. +Everything is written to `~/.ucode/managed-state.json` — the editable local draft — which +`ucode apply` publishes. Re-running `ucode setup` keeps the MCP servers, skills, tracing +table, and tiered spend policy already authored, rather than clearing them; to drop one, edit the +file and reload it with `ucode setup --from-file`. ```bash # Review the manifest and the exact payload `ucode apply` would publish. @@ -251,6 +253,18 @@ ucode setup show ucode setup --from-file ./managed-config.json ``` +To test before publishing, add `--local` to a launch. The override lasts for that invocation only: + +```bash +ucode --local # test the draft's configured default agent +ucode claude --local # or test a specific enabled agent +``` + +Without `--local`, launches always use the workspace-published config when one exists. The local +draft is never turned into a persistent mode and ordinary launches never overwrite it, so an admin +can edit and test it repeatedly. Spend-tier recommendations begin after publication because they +are resolved server-side. + Once the manifest looks right, publish it: ```bash @@ -304,7 +318,9 @@ their next ucode run. | `ucode setup help` | Walk through the whole setup sequence, marking what's already configured | | `ucode setup show` | Print the authored config and the payload `ucode apply` would publish | | `ucode setup --from-file ` | Load a hand-written managed config instead of running the prompts | -| `ucode apply` | Publish the authored managed config to the workspace, after a diff and confirmation (admins only) | +| `ucode --local` | Launch the draft's default agent for an optional local test | +| `ucode --local` | Launch a specific agent using the local draft for this invocation only | +| `ucode apply` | Publish the latest local draft to the workspace, whether or not it was tested | | `ucode apply --yes` | Publish without the confirmation prompt | ## Managed Local Files @@ -320,7 +336,8 @@ their next ucode run. | `~/.copilot/.env` | GitHub Copilot CLI | | `~/.pi/agent/models.json` | Pi | | `~/.cursor/mcp.json` | Cursor Agent (MCP servers only) | -| `~/.ucode/managed-state.json` | The managed config — authored by `ucode setup` (admins) and refreshed from the workspace on launch | +| `~/.ucode/managed-state.json` | The editable managed-config draft authored by `ucode setup` | +| `~/.ucode/managed-cache/.json` | Per-workspace published-config cache with fetch metadata | Existing files are backed up before being overwritten. `ucode revert` restores backups. diff --git a/pyproject.toml b/pyproject.toml index 27eb0619..79f53553 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,9 @@ dependencies = [ # transitively by mcp itself); `ucode.mcp_proxy` selects whichever the # installed SDK uses at runtime, so no direct httpx2 dependency is declared. "httpx>=0.27.1", + # Polished searchable/selectable prompts for the managed-config setup wizard. Questionary remains + # in use for the specialized MCP pickers, whose custom viewport and toolbar depend on its API. + "InquirerPy>=0.3.4", # `ucode mcp-proxy` bridges a client's stdio MCP transport to a Databricks # streamable-HTTP MCP endpoint, injecting a freshly-minted OAuth bearer per # request, via the SDK's stdio server + streamable-HTTP client. Works against diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 910af654..4dd74df1 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -63,8 +63,8 @@ render_budget_panel, ) from ucode.managed_config import ( - MANAGED_CONFIG_ENV_VAR, get_model_recommendation, + load_managed_cache, load_managed_state, managed_agent_config_enabled, refresh_managed_config, @@ -279,9 +279,9 @@ def _maybe_offer_admin_setup(workspace: str, profile: str | None) -> None: if not is_admin: return print_note( - "✨ New: run `ucode setup` to publish a managed config to a workspace — set agents, models, mcps " - "and skills once, and every developer inherits them when running `ucode`. This scales " - "delivery of coding agents to all developers without each one setting up ucode themselves." + "✨ New: run `ucode setup` to publish a managed config to a workspace — set agents, models, " + "MCP servers, and skills once, and every developer inherits them when running `ucode`. " + "This scales delivery of coding agents without each developer setting up ucode themselves." ) @@ -307,14 +307,19 @@ def _print_discovery_diagnostics(state: dict) -> None: print_note("Re-run with `UCODE_DEBUG=1` to log raw discovery responses to ~/.ucode/debug.log.") -def _prompt_for_configuration(tool: str | None = None) -> tuple[str, str | None]: +def _prompt_for_configuration( + tool: str | None = None, + *, + show_section: bool = True, + prompt: str = "Select workspace:", +) -> tuple[str, str | None]: if tool is None: desc = "Configure your Databricks workspace" else: desc = f"Configure {TOOL_SPECS[tool]['display']} to use your Databricks endpoint." with spinner("Loading Databricks workspaces and profiles..."): profiles = get_databricks_profiles() - return prompt_for_workspace(desc, profiles) + return prompt_for_workspace(desc, profiles, show_section=show_section, prompt=prompt) def _parse_agents_option(agents: str) -> list[str]: @@ -425,6 +430,7 @@ def configure_shared_state( use_pat: bool | None = None, skip_model_discovery: bool = False, skip_preflight: bool = False, + connection_checks_verified: bool = False, fable_enabled: bool | None = None, databricks_ai_tools_enabled: bool | None = None, ) -> dict: @@ -446,6 +452,9 @@ def configure_shared_state( in ``_launch_tool``) and the gateway was verified by that earlier configure. Only the local profile resolution and the shared state assembly still run; the saved model lists are preserved. + ``connection_checks_verified`` skips only the auth and AI Gateway probes while still running + model discovery. This is for callers such as ``ucode setup`` that perform those checks earlier + in their own ordered UI; it prevents duplicate network calls and success messages. ``fable_enabled`` opts the premium Claude Fable family into Claude Code's ``ANTHROPIC_DEFAULT_FABLE_MODEL`` pin (default off). ``None`` means "inherit": a launch re-run keeps whatever the workspace was configured with; ``True``/ @@ -534,21 +543,27 @@ def configure_shared_state( # empty one as absent, so it never shadows the PAT. Pass the validated # token to avoid re-reading ~/.databrickscfg. ensure_pat_bearer(profile, pat) - ensure_databricks_auth(workspace, profile) - elif force_login: - run_databricks_login(workspace, profile) - else: - ensure_databricks_auth(workspace, profile) + if not connection_checks_verified: + ensure_databricks_auth(workspace, profile) + elif not connection_checks_verified: + if force_login: + run_databricks_login(workspace, profile) + else: + ensure_databricks_auth(workspace, profile) # After login the profile exists in ~/.databrickscfg, so a host->profile # lookup is reliable even when it returned nothing above. if profile is None: profile = find_profile_name_for_host(workspace) if profile: state["profile"] = profile - with spinner("Verifying Unity AI Gateway..."): - token = get_databricks_token(workspace, profile) - ensure_ai_gateway(workspace, token) - print_success("Unity AI Gateway detected") + # Discovery always needs a bearer token. ``connection_checks_verified`` means the caller + # already performed the visible auth and gateway probes; it does not mean this function can + # skip obtaining the token used by the model-list APIs below. + token = get_databricks_token(workspace, profile) + if not connection_checks_verified: + with spinner("Verifying Unity AI Gateway..."): + ensure_ai_gateway(workspace, token) + print_success("Unity AI Gateway detected") want_claude = ( fetch_all or "claude" in tools or "opencode" in tools or "copilot" in tools or "pi" in tools @@ -917,7 +932,6 @@ def status() -> int: profile = state.get("profile") if profile: print_kv("CLI profile", profile) - print_heading("Coding Agents") for tool, spec in TOOL_SPECS.items(): configured = tool in configured_tools @@ -1428,8 +1442,7 @@ def _reject_disabled_agent(managed: dict | None, tool: str) -> None: def _fetch_managed_config(state: dict) -> tuple[dict | None, bool]: """The workspace's managed config for this launch, or ``(None, _)`` when there is none. - Returns ``(None, False)`` when managed configs are switched off — either the feature is disabled - or the launch passed ``--skip-managed-config`` (which clears the enabling env var for the process). + Returns ``(None, False)`` when the managed-config feature is disabled. """ if not managed_agent_config_enabled(): @@ -1597,6 +1610,37 @@ def _apply_managed_skills(managed: dict, tool: str, state: dict) -> None: _download_managed_skills(managed, state) +def _require_local_config_admin(state: dict) -> None: + """Fail closed unless the current user is an admin of the draft's workspace. + + Unlike ``setup`` and ``apply``, a local launch has no workspace API operation that can enforce + authorization server-side. An unknown SCIM result therefore has to block the launch rather + than optimistically continuing. + """ + workspace = state.get("workspace") + if not isinstance(workspace, str) or not workspace: + raise RuntimeError("No workspace configured. Run `ucode configure` first.") + try: + token = get_databricks_token(workspace, state.get("profile")) + with spinner("Checking workspace admin permissions..."): + admin = is_workspace_admin(workspace, token) + except RuntimeError as exc: + raise RuntimeError( + "Could not verify workspace admin permissions for `--local`: " + f"{exc}. Check your Databricks authentication and retry." + ) from exc + if admin is False: + raise RuntimeError( + f"You are not an admin of {workspace}. `--local` runs an unpublished workspace-wide " + "coding config, so it is restricted to workspace admins." + ) + if admin is None: + raise RuntimeError( + "Could not verify workspace admin permissions for `--local`. Check your Databricks " + "authentication and workspace connectivity, then retry." + ) + + def _launch_tool( tool_name: str, ctx: typer.Context, @@ -1607,6 +1651,8 @@ def _launch_tool( managed: dict | None = None, recommendation: dict | None = None, model: str | None = None, + local_config: bool = False, + local_admin_verified: bool = False, ) -> None: try: tool = normalize_tool(tool_name) @@ -1642,7 +1688,16 @@ def _launch_tool( # at all and whether the model discovery below can be skipped. # Bare `ucode` already fetched one to choose the agent; refetching would double the # control-plane round trip and any fallback warning it printed. - if managed is None: + if local_config: + if managed is None: + managed = load_managed_state(state["workspace"]) + if not managed: + raise RuntimeError( + "No local managed config was found for this workspace. Run `ucode setup` first." + ) + if not local_admin_verified: + _require_local_config_admin(state) + elif managed is None: managed, _coding_agent_config_feature_disabled = _fetch_managed_config(state) # Checked before discovery, which can take tens of seconds, so a blocked launch fails fast. _reject_disabled_agent(managed, tool) @@ -1664,12 +1719,20 @@ def _launch_tool( # `configure_shared_state`, whose returned state it overrides, and before the provider and # model are settled below — the two state files are never merged on disk. # Bare `ucode` already read one to choose the agent; refetching would double the round trip. - if recommendation is None: + if recommendation is None and not local_config: recommendation = _fetch_budget_recommendation(state, managed) + if local_config and managed is not None and managed.get("budget_policy"): + print_note( + "The local config uses its baseline agent and model; spend-tier routing is " + "resolved from the published workspace config." + ) _note_recommended_agent(recommendation, tool) if managed is not None: state = resolve_state(managed, state, tool) - print_success("Applied your workspace's managed coding agent config") + if local_config: + print_success("Applied your local managed coding agent config") + else: + print_success("Applied your workspace's managed coding agent config") unservable = managed_unservable_models(managed, tool) if unservable: print_warning( @@ -1818,7 +1881,7 @@ def _launch_tool( ) print_section(f"ucode with {TOOL_SPECS[tool]['display']}") if managed is not None: - print_kv("Config", "workspace-managed") + print_kv("Config", "local" if local_config else "workspace-managed") if provider: print_kv("Provider", provider) elif model and tool == "claude": @@ -1849,7 +1912,7 @@ def _launch_tool( # Register the managed config's MCP servers so they reach the agent's `/mcp` list. Nothing # else on this path does it — the config only lists them — so without this a # workspace-published server never shows up. `managed` is already None when the config is - # skipped (--skip-managed-config / feature off); --dry-run writes nothing. + # disabled by the feature gate; --dry-run writes nothing. if managed is not None and not is_dry_run(): _register_managed_mcp_servers(managed, tool, state) _apply_managed_skills(managed, tool, state) @@ -1866,8 +1929,7 @@ def _launch_tool( # Launch-only escape hatch for managed/headless launchers (e.g. omnigent) that # have already run `ucode configure`: skip the ~5-10s per-launch auth + AI # Gateway re-validation. Distinct from the configure-only `--skip-validate`, -# which skips the model smoke test, and from `--skip-managed-config`, which -# controls whether the workspace's managed config is applied. +# which skips the model smoke test. SkipPreflightOption = Annotated[ bool, typer.Option( @@ -1877,30 +1939,34 @@ def _launch_tool( ), ] -# Ignore the workspace's managed coding-agent config for this one command, on both -# `ucode configure` and the launchers. Accepted (and no-op) even when the managed-config -# feature is off, so a headless launcher can always pass it. -SkipManagedConfigOption = Annotated[ +# Reserve the removed flag so launchers do not forward it to the underlying agent as an unknown +# argument. It no longer provides any managed-config bypass. +RemovedSkipManagedConfigOption = Annotated[ bool, typer.Option( "--skip-managed-config", - help="Ignore your workspace's managed coding-agent config for this run, as if managed " - "configs were switched off — use your own local settings instead.", + help="Removed: workspace managed configs can no longer be bypassed.", hidden=True, ), ] +LocalManagedConfigOption = Annotated[ + bool, + typer.Option( + "--local", + help="Workspace admins only: use the latest local config authored by `ucode setup` for " + "this launch instead of the published workspace config.", + ), +] -def _disable_managed_config_if_requested(skip_managed_config: bool) -> None: - """Make this process behave as though ``ENABLE_MANAGED_AGENT_CONFIG`` were never set. - ``managed_agent_config_enabled()`` reads the env var live and gates every managed-config path - (the launch fetch/apply, the budget read, MCP registration, the bare-``ucode`` agent picker, and - the ``configure`` reject-under-managed flow), so clearing it once here short-circuits them all - without threading a flag through each. Per-invocation only: it affects just the current command. - """ +def _reject_removed_skip_managed_config(skip_managed_config: bool) -> None: if skip_managed_config: - os.environ.pop(MANAGED_CONFIG_ENV_VAR, None) + print_err( + "`--skip-managed-config` has been removed. Normal launches use the workspace's " + "managed config; workspace admins can test a local draft with `--local`." + ) + raise typer.Exit(2) # Target this launch at a specific workspace, auto-configuring (and logging in) @@ -1937,7 +2003,8 @@ def default( ), ] = False, skip_preflight: SkipPreflightOption = False, - skip_managed_config: SkipManagedConfigOption = False, + skip_managed_config: RemovedSkipManagedConfigOption = False, + local_config: LocalManagedConfigOption = False, workspace: WorkspaceOption = None, ) -> None: """Configure and launch coding agents through Databricks AI Gateway. @@ -1947,10 +2014,14 @@ def default( if ctx.invoked_subcommand is not None: return set_dry_run(dry_run) - _disable_managed_config_if_requested(skip_managed_config) + _reject_removed_skip_managed_config(skip_managed_config) try: _launch_managed_default( - ctx, dry_run=dry_run, skip_preflight=skip_preflight, workspace=workspace + ctx, + dry_run=dry_run, + skip_preflight=skip_preflight, + workspace=workspace, + local_config=local_config, ) except typer.Exit: # `typer.Exit` subclasses RuntimeError, so it has to be re-raised ahead of the handler @@ -1968,9 +2039,10 @@ def _launch_managed_default( dry_run: bool, skip_preflight: bool, workspace: str | None, + local_config: bool, ) -> None: """Route bare ``ucode`` by whether the workspace publishes a managed config.""" - if not managed_agent_config_enabled(): + if not managed_agent_config_enabled() and not local_config: console.print(ctx.get_help()) return if workspace: @@ -1981,19 +2053,27 @@ def _launch_managed_default( if not current: raise RuntimeError("No workspace configured. Run `ucode configure` first.") apply_pat_environment(state) - # --dry-run avoids the fetch but still applies the last saved config. - if dry_run: + coding_agent_config_feature_disabled = False + # Both avoid a control-plane fetch, but they intentionally read different files: --local uses + # the admin's editable draft, while an ordinary dry-run stays on the published-config cache. + if local_config: managed = load_managed_state(current) + elif dry_run: + managed = load_managed_cache(current) else: with spinner("Loading..."): managed, coding_agent_config_feature_disabled = refresh_managed_config(state) + if local_config and not managed: + raise RuntimeError("No local managed config was found. Run `ucode setup` first.") + if local_config: + _require_local_config_admin(state) if not managed and not coding_agent_config_feature_disabled: _print_no_managed_config_guidance(current, state.get("profile")) if not managed: return # The budget tier can move the org to a cheaper agent, so it outranks the config's # default_agent. Fetched here and handed to _launch_tool so it is read once per launch. - recommendation = _fetch_budget_recommendation(state, managed) + recommendation = None if local_config else _fetch_budget_recommendation(state, managed) tool = recommended_agent(recommendation, managed) or next( iter(managed.get("enabled_agents") or {}), None ) @@ -2010,6 +2090,8 @@ def _launch_managed_default( workspace=workspace, managed=managed, recommendation=recommendation, + local_config=local_config, + local_admin_verified=local_config, ) @@ -2044,7 +2126,8 @@ def codex_cmd( ), ] = None, skip_preflight: SkipPreflightOption = False, - skip_managed_config: SkipManagedConfigOption = False, + skip_managed_config: RemovedSkipManagedConfigOption = False, + local_config: LocalManagedConfigOption = False, workspace: WorkspaceOption = None, enable_smart_routing_flag: Annotated[ bool, @@ -2062,7 +2145,7 @@ def codex_cmd( ] = False, ) -> None: """Launch Codex via Databricks.""" - _disable_managed_config_if_requested(skip_managed_config) + _reject_removed_skip_managed_config(skip_managed_config) if enable_smart_routing_flag and disable_smart_routing_flag: print_err("Use only one of --enable-smart-routing or --disable-smart-routing.") raise typer.Exit(1) @@ -2077,6 +2160,7 @@ def codex_cmd( skip_preflight=skip_preflight, workspace=workspace, enable_smart_routing_flag=enable_smart_routing_flag, + local_config=local_config, ) @@ -2103,7 +2187,8 @@ def claude_cmd( ), ] = None, skip_preflight: SkipPreflightOption = False, - skip_managed_config: SkipManagedConfigOption = False, + skip_managed_config: RemovedSkipManagedConfigOption = False, + local_config: LocalManagedConfigOption = False, workspace: WorkspaceOption = None, enable_smart_routing_flag: Annotated[ bool, @@ -2121,7 +2206,7 @@ def claude_cmd( ] = False, ) -> None: """Launch Claude Code via Databricks.""" - _disable_managed_config_if_requested(skip_managed_config) + _reject_removed_skip_managed_config(skip_managed_config) if enable_smart_routing_flag and disable_smart_routing_flag: print_err("Use only one of --enable-smart-routing or --disable-smart-routing.") raise typer.Exit(1) @@ -2137,6 +2222,7 @@ def claude_cmd( skip_preflight=skip_preflight, workspace=workspace, enable_smart_routing_flag=enable_smart_routing_flag, + local_config=local_config, ) @@ -2144,11 +2230,12 @@ def claude_cmd( def gemini_cmd( ctx: typer.Context, skip_preflight: SkipPreflightOption = False, - skip_managed_config: SkipManagedConfigOption = False, + skip_managed_config: RemovedSkipManagedConfigOption = False, + local_config: LocalManagedConfigOption = False, ) -> None: """Launch Gemini CLI via Databricks.""" - _disable_managed_config_if_requested(skip_managed_config) - _launch_tool("gemini", ctx, skip_preflight=skip_preflight) + _reject_removed_skip_managed_config(skip_managed_config) + _launch_tool("gemini", ctx, skip_preflight=skip_preflight, local_config=local_config) @app.command( @@ -2157,33 +2244,36 @@ def gemini_cmd( def opencode_cmd( ctx: typer.Context, skip_preflight: SkipPreflightOption = False, - skip_managed_config: SkipManagedConfigOption = False, + skip_managed_config: RemovedSkipManagedConfigOption = False, + local_config: LocalManagedConfigOption = False, ) -> None: """Launch OpenCode via Databricks.""" - _disable_managed_config_if_requested(skip_managed_config) - _launch_tool("opencode", ctx, skip_preflight=skip_preflight) + _reject_removed_skip_managed_config(skip_managed_config) + _launch_tool("opencode", ctx, skip_preflight=skip_preflight, local_config=local_config) @app.command("copilot", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) def copilot_cmd( ctx: typer.Context, skip_preflight: SkipPreflightOption = False, - skip_managed_config: SkipManagedConfigOption = False, + skip_managed_config: RemovedSkipManagedConfigOption = False, + local_config: LocalManagedConfigOption = False, ) -> None: """Launch GitHub Copilot CLI via Databricks.""" - _disable_managed_config_if_requested(skip_managed_config) - _launch_tool("copilot", ctx, skip_preflight=skip_preflight) + _reject_removed_skip_managed_config(skip_managed_config) + _launch_tool("copilot", ctx, skip_preflight=skip_preflight, local_config=local_config) @app.command("pi", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) def pi_cmd( ctx: typer.Context, skip_preflight: SkipPreflightOption = False, - skip_managed_config: SkipManagedConfigOption = False, + skip_managed_config: RemovedSkipManagedConfigOption = False, + local_config: LocalManagedConfigOption = False, ) -> None: """Launch Pi coding agent via Databricks.""" - _disable_managed_config_if_requested(skip_managed_config) - _launch_tool("pi", ctx, skip_preflight=skip_preflight) + _reject_removed_skip_managed_config(skip_managed_config) + _launch_tool("pi", ctx, skip_preflight=skip_preflight, local_config=local_config) @app.command("cursor", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) @@ -2334,7 +2424,7 @@ def configure( "still applied.", ), ] = False, - skip_managed_config: SkipManagedConfigOption = False, + skip_managed_config: RemovedSkipManagedConfigOption = False, verbose: Annotated[ str, typer.Option( @@ -2347,7 +2437,7 @@ def configure( """Configure workspace URL and AI Gateway.""" if ctx.invoked_subcommand is not None: return - _disable_managed_config_if_requested(skip_managed_config) + _reject_removed_skip_managed_config(skip_managed_config) if verbose not in ("normal", "low"): print_err("--verbose must be one of: normal, low.") raise typer.Exit(2) diff --git a/src/ucode/managed_config.py b/src/ucode/managed_config.py index 5cde1127..6d9c97fe 100644 --- a/src/ucode/managed_config.py +++ b/src/ucode/managed_config.py @@ -1,20 +1,18 @@ -"""Admin-authored managed coding-agent config: fetch, normalize, and local persistence. +"""Managed coding-agent config: fetch, normalize, and local persistence. An org admin authors a ``CodingAgentConfig`` through the Databricks AI Gateway; developers read it -(non-admin) and ``ucode`` applies it locally. This module owns the fetch/normalize side and the one -local file, ``~/.ucode/managed-state.json`` (0600), that both roles share: +(non-admin) and ``ucode`` applies it locally. This module owns the fetch/normalize side and keeps +the admin's local draft separate from the last published config fetched by a launch: - fetching the raw manifest (via :func:`ucode.databricks.fetch_managed_coding_agent_configs`), - normalizing the proto-JSON into a stable internal dict keyed by ucode's own tool names, -- persisting it via :func:`save_managed_state` / :func:`load_managed_state` — the admin-write side - (``managed_setup`` / ``managed_wizard``) authors the manifest here, and the launch path pulls the - published copy back into the same file, and -- re-reading it on each launch, falling back to the persisted copy when the read fails. +- ``~/.ucode/managed-state.json`` is the editable draft authored by ``ucode setup`` and published by + ``ucode apply``; +- ``~/.ucode/managed-cache/.json`` is launch-owned and contains only the last + workspace-published config plus provenance metadata, for outage fallback. -There is deliberately one file, not a separate authored ``managed-settings.json``: the workspace is -the source of truth, so an authored draft and the pulled copy are the same shape and coexist in -``managed-state.json``. ``ucode setup`` authors the draft; ``ucode apply`` publishes it; a launch -then pulls the published copy back into the same file. +Keeping the files separate is important: an ordinary launch must never overwrite or accidentally +apply an admin's unpublished edits. Only a launch with ``--local`` reads the authored draft. :func:`refresh_managed_config` is the launch path's entry point. It is called before model discovery, because the manifest decides whether that discovery is needed at all; the launch path then hands the @@ -25,8 +23,11 @@ from __future__ import annotations +import hashlib import json import os +import tempfile +from datetime import UTC, datetime from pathlib import Path from typing import cast @@ -39,6 +40,11 @@ from ucode.ui import console, print_warning MANAGED_STATE_PATH = config_io.APP_DIR / "managed-state.json" +MANAGED_CACHE_DIR = config_io.APP_DIR / "managed-cache" +# Read-only migration fallback for the single-cache layout used by earlier builds. New writes always +# go to MANAGED_CACHE_DIR so switching workspaces cannot discard another workspace's fallback. +LEGACY_MANAGED_CACHE_PATH = config_io.APP_DIR / "managed-cache.json" +MANAGED_STATE_SCHEMA_VERSION = "1.0" # Opt-in switch while the feature is in bug bash: unset means launches ignore managed configs # entirely and behave exactly as they did before. @@ -341,29 +347,89 @@ def _is_permission_denied(reason: str) -> bool: return "http 403" in lowered or "permission_denied" in lowered -def save_managed_state(workspace: str, config: dict) -> None: - """Persist the normalized managed config to ``~/.ucode/managed-state.json`` at mode 0600. +def _config_digest(config: dict) -> str: + canonical = json.dumps(config, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return f"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}" + + +def _managed_cache_path(workspace: str) -> Path: + """Return a stable, filesystem-safe cache path for one workspace.""" + workspace_hash = hashlib.sha256(workspace.encode("utf-8")).hexdigest() + return MANAGED_CACHE_DIR / f"{workspace_hash}.json" - The file is org-authored, not developer-editable — 0600 keeps it readable/writable only by the - user (a light guard; hard enforcement / sudo ownership is a separate concern). No-op in dry-run. - An empty ``config`` records "this workspace has no managed config", which matters because the - file doubles as the fallback when a later read fails: without it, removing a config server-side - would leave the old one on disk to be reapplied after a transient outage. +def _save_managed_payload( + path: Path, + workspace: str, + config: dict, + *, + metadata: dict[str, object] | None = None, +) -> None: + """Atomically write one versioned managed-config envelope at mode 0600. + + The temporary file is created beside the destination, flushed, and replaced into place. Readers + therefore observe either the complete old document or the complete new one, including when two + launches refresh the same cache concurrently. """ - payload = {"workspace": workspace, "config": config} + # Dict insertion order is preserved by json.dumps: keep the format version first so humans and + # future readers can identify the file schema before interpreting any payload fields. Version + # 1.0 is the only managed-state schema this ucode release writes. + payload: dict[str, object] = { + "schema_version": MANAGED_STATE_SCHEMA_VERSION, + "workspace": workspace, + } + if metadata is not None: + payload["metadata"] = metadata + payload["config"] = config if config_io.is_dry_run(): # Print rather than write, matching how the agent config writers behave under --dry-run. - console.print( - f"\n[bold]\\[dry run] {MANAGED_STATE_PATH}[/bold]\n{json.dumps(payload, indent=2)}\n" - ) + console.print(f"\n[bold]\\[dry run] {path}[/bold]\n{json.dumps(payload, indent=2)}\n") return - config_io.ensure_parent_dir(MANAGED_STATE_PATH) + config_io.ensure_parent_dir(path) + serialized = json.dumps(payload, indent=2) + "\n" + temporary_path: Path | None = None try: - MANAGED_STATE_PATH.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as temporary: + temporary_path = Path(temporary.name) + temporary.write(serialized) + temporary.flush() + os.fsync(temporary.fileno()) + _restrict_permissions(temporary_path) + os.replace(temporary_path, path) except OSError as exc: - raise RuntimeError(f"Failed to write managed state file: {MANAGED_STATE_PATH}") from exc - _restrict_permissions(MANAGED_STATE_PATH) + raise RuntimeError(f"Failed to write managed config file: {path}") from exc + finally: + if temporary_path is not None: + try: + temporary_path.unlink(missing_ok=True) + except OSError: + pass + + +def save_managed_state(workspace: str, config: dict) -> None: + """Save the admin-authored draft to ``~/.ucode/managed-state.json``.""" + _save_managed_payload(MANAGED_STATE_PATH, workspace, config) + + +def save_managed_cache(workspace: str, config: dict) -> None: + """Save the last workspace-published config for launch fallback. + + An empty config records an authoritative successful read of "no published config", preventing + an older cached policy from being resurrected after a later transient failure. + """ + metadata: dict[str, object] = { + "source": "workspace", + "fetched_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + "config_digest": _config_digest(config), + } + _save_managed_payload(_managed_cache_path(workspace), workspace, config, metadata=metadata) def _restrict_permissions(path: Path) -> None: @@ -375,45 +441,106 @@ def _restrict_permissions(path: Path) -> None: pass +def _read_managed_state_v1(data: dict) -> tuple[str, dict] | None: + """Parse the v1.0 managed-state envelope. + + Keeping this version-specific prevents a future schema from being accidentally interpreted as + v1 just because it happens to reuse a field name. New versions add a new reader and registry + entry rather than accumulating conditionals in the launch path. + """ + workspace = data.get("workspace") + config = data.get("config") + if not isinstance(workspace, str) or not workspace or not isinstance(config, dict): + return None + return workspace, config + + +_MANAGED_STATE_READERS = {"1.0": _read_managed_state_v1} + + +def _read_managed_payload( + path: Path, *, require_cache_metadata: bool = False +) -> tuple[str, dict] | None: + data = config_io.read_json_safe(path) + version = data.get("schema_version") + if version is None: + # Files written before schema_version was introduced already use the v1.0 envelope. Read + # them as v1 so upgrading ucode does not discard an otherwise valid cached config. + version = MANAGED_STATE_SCHEMA_VERSION + if not isinstance(version, str): + return None + reader = _MANAGED_STATE_READERS.get(version) + if reader is None: + # An older ucode must not guess how to interpret a future managed-state schema. + return None + payload = reader(data) + if payload is None or not require_cache_metadata: + return payload + _workspace, config = payload + metadata = data.get("metadata") + if not isinstance(metadata, dict): + return None + if metadata.get("source") != "workspace": + return None + if metadata.get("config_digest") != _config_digest(config): + return None + fetched_at = metadata.get("fetched_at") + if not isinstance(fetched_at, str) or not fetched_at: + return None + return payload + + def load_managed_state(workspace: str | None) -> dict | None: - """Load the persisted managed config for ``workspace``, or None if absent/mismatched. + """Load the admin-authored draft for ``workspace``, or None if absent/mismatched. Returns the normalized config dict (the ``config`` field), only when the stored file is for the same workspace — so a stale file from another workspace is ignored rather than misapplied. - This is the single local managed config: ``ucode setup`` authors it here, ``ucode apply`` - publishes it, and a launch refreshes it from the workspace. The admin-authored draft and the - pulled copy share one file because the workspace is the source of truth — to keep a draft, - publish it with ``ucode apply``. + ``ucode setup`` authors this file, ``ucode --local`` tests it, and ``ucode apply`` + publishes it. Ordinary launches never write or read it. """ if not workspace: return None - data = config_io.read_json_safe(MANAGED_STATE_PATH) - if data.get("workspace") != workspace: + payload = _read_managed_payload(MANAGED_STATE_PATH) + if payload is None: return None - config = data.get("config") - return config if isinstance(config, dict) else None + stored_workspace, config = payload + return config if stored_workspace == workspace else None + + +def load_managed_cache(workspace: str | None) -> dict | None: + """Load the last workspace-published config cached by an ordinary launch.""" + if not workspace: + return None + cache_path = _managed_cache_path(workspace) + payload = _read_managed_payload(cache_path, require_cache_metadata=True) + if payload is None and not cache_path.exists(): + payload = _read_managed_payload(LEGACY_MANAGED_CACHE_PATH) + if payload is None: + return None + stored_workspace, config = payload + return config if stored_workspace == workspace else None def managed_state_workspace() -> str | None: - """The workspace the on-disk managed config was authored/pulled for, or None when there is none. + """The workspace the on-disk draft was authored for, or None when there is none. Lets a caller that has no workspace in local ucode state (e.g. ``ucode setup --show`` before ``ucode configure``) still find the manifest on disk and report which workspace it belongs to. """ - workspace = config_io.read_json_safe(MANAGED_STATE_PATH).get("workspace") - return workspace if isinstance(workspace, str) and workspace else None + payload = _read_managed_payload(MANAGED_STATE_PATH) + return payload[0] if payload is not None else None def refresh_managed_config(state: dict) -> tuple[dict | None, bool]: - """Fetch the workspace's managed config and persist it, returning ``(manifest, coding_agent_config_feature_disabled)``. + """Fetch and cache the workspace-published config. Runs on every launch so a developer picks up an admin's edits without re-running ``ucode configure``. The manifest is None when the workspace has no managed config — the normal case for a workspace whose admin hasn't published one. A failed fetch never blocks the launch: an unreachable control plane shouldn't stop someone from - coding. Instead it falls back to the last config persisted for this workspace, so the admin's + coding. Instead it falls back to the last published config cached for this workspace, so the admin's most recent known policy still applies; only when there is no persisted config either does the launch fall through to the developer's own settings. @@ -438,9 +565,9 @@ def refresh_managed_config(state: dict) -> tuple[dict | None, bool]: # Record that this workspace has no config, rather than leaving an earlier one on disk: # the file doubles as the fallback above, so a removed policy would otherwise come back # into force after the next transient outage. - save_managed_state(workspace, {}) + save_managed_cache(workspace, {}) return None, False - save_managed_state(workspace, managed) + save_managed_cache(workspace, managed) return managed, False @@ -459,7 +586,7 @@ def _persisted_fallback(workspace: str, reason: str, *, refused: bool = False) - """ # An empty persisted config means the last successful read found none, so there is no admin # policy to fall back to — treat it the same as having no file at all. - persisted = load_managed_state(workspace) + persisted = load_managed_cache(workspace) if not persisted: return None summary = _summarize_read_failure(reason) diff --git a/src/ucode/managed_resolve.py b/src/ucode/managed_resolve.py index 811b9f1f..c7b00492 100644 --- a/src/ucode/managed_resolve.py +++ b/src/ucode/managed_resolve.py @@ -1,10 +1,9 @@ """Resolve the effective agent settings from the managed config plus local ucode state. -The managed config (``~/.ucode/managed-state.json`` — authored by ``ucode setup`` and refreshed -from the workspace at launch, both through :mod:`ucode.managed_config`) and the developer's own -ucode state (``~/.ucode/state.json``) stay separate files — they are never merged on disk. Instead -this module resolves them *per key* at config-write time: whatever the manifest specifies wins, and -anything it leaves unset falls back to +The selected managed config (the workspace-published config normally, or the local setup draft when +``--local`` is passed) and the developer's own ucode state (``~/.ucode/state.json``) are never merged +on disk. Instead this module resolves them *per key* at config-write time: whatever the manifest +specifies wins, and anything it leaves unset falls back to the developer's ucode state. The resolved view is what gets rendered into the agent config files (e.g. ``~/.claude/ucode-settings.json``), so managed settings take precedence for every ``ucode`` command without either file being rewritten. diff --git a/src/ucode/managed_setup.py b/src/ucode/managed_setup.py index a26e185b..985bd524 100644 --- a/src/ucode/managed_setup.py +++ b/src/ucode/managed_setup.py @@ -13,9 +13,10 @@ inverting that module's maps rather than restated, so a new agent or MCP type only has to be added once. -Local persistence is not duplicated here: the authored manifest is saved to and loaded from the one -local file, ``~/.ucode/managed-state.json``, via :func:`ucode.managed_config.save_managed_state` and -:func:`ucode.managed_config.load_managed_state` — the same file the launch path pulls into. +Local persistence is not duplicated here: the authored manifest is saved to and loaded from +``~/.ucode/managed-state.json`` via :func:`ucode.managed_config.save_managed_state` and +:func:`ucode.managed_config.load_managed_state`. Ordinary launches keep their published-config +fallback in a separate cache, so they cannot overwrite unpublished edits. The interactive wizard that calls these helpers, and the publish step, live in :mod:`ucode.managed_wizard`. diff --git a/src/ucode/managed_wizard.py b/src/ucode/managed_wizard.py index 5f7be5d9..c5c72db4 100644 --- a/src/ucode/managed_wizard.py +++ b/src/ucode/managed_wizard.py @@ -1,9 +1,9 @@ """Interactive `ucode setup`: author the workspace's managed coding-agent config. Workspace admins run this to build the ``CodingAgentConfig`` their developers will pull, then publish -it with ``ucode apply`` (a separate command, so the manifest can be reviewed first). The config lives -at ``~/.ucode/managed-state.json`` (the one local managed-config file, owned by -:mod:`ucode.managed_config`). +it with ``ucode apply`` (a separate command, so the manifest can be reviewed or tested first). The +editable draft lives at ``~/.ucode/managed-state.json`` and is never overwritten by ordinary +workspace-managed launches. Authoring is split across commands so an admin can change one part without walking the whole flow: ``ucode setup`` picks the agents and models, and ``ucode setup mcps`` / ``skills`` / ``spend-tiers`` @@ -32,6 +32,7 @@ create_coding_agent_config, delete_coding_agent_config, discover_claude_models_unbucketed, + ensure_ai_gateway, ensure_databricks_auth, get_databricks_token, has_cached_model_provider_services, @@ -64,6 +65,7 @@ from ucode.ui import ( console, format_usd, + inquirerpy_wizard, kv_line, print_err, print_heading, @@ -73,6 +75,9 @@ print_success, print_warning, print_warning_panel, + print_wizard_header, + print_wizard_outro, + print_wizard_step, prompt_for_multi_selection, prompt_for_percentage, prompt_for_selection, @@ -82,14 +87,6 @@ spinner, ) -# The OS-level managed settings file `use_as_global_settings` writes for each agent — named in the -# prompt so an admin sees exactly what answering "yes" touches. Yes writes this file (needs sudo -# once) so a bare `claude`/`codex` reaches the gateway on its own; no keeps it ucode-only. -GLOBAL_SETTINGS_FILES = { - "claude": "managed-settings.json", - "codex": "managed_config.toml", -} - BUDGET_POLICY_BLURB = ( "As the workspace spends more of a budget, a tiered spend policy automatically switches " "everyone's default agent and model to a cheaper one — for example Claude Code / Opus normally, " @@ -412,8 +409,8 @@ def _confirm_agent(tool: str, agent_config: dict) -> None: """One consistent closing line per agent in step 2, whatever its model shape. Every agent — a single-model codex, a multi-model opencode, a family-slotted claude — ends its - block with the same `✔ configured — · ` line, so the step reads as a - uniform checklist rather than each agent's picker trailing off differently. + block with the same `✔ configured — ` line, so the step reads as a uniform + checklist rather than each agent's picker trailing off differently. """ display = TOOL_SPECS.get(tool, {}).get("display", tool) model_config = agent_config.get("model_config") or {} @@ -421,26 +418,9 @@ def _confirm_agent(tool: str, agent_config: dict) -> None: provider = model_config.get("model_provider_service") if provider: detail = f"{detail} via {provider}" - if tool in GLOBAL_SETTINGS_AGENTS: - scope = "global settings" if agent_config.get("use_as_global_settings") else "ucode-only" - detail = f"{detail} · {scope}" print_success(f"{display} configured — {detail}") -def _render_family_slots(slots: dict[str, str]) -> None: - """Recap the Claude family → model slots just chosen, before the overall-default question. - - Same "form filling in" motif as :func:`_selected_recap`: the per-family answers scrolled by one at - a time, so gathering them into one box makes "which of these is the overall default?" a choice - over something the admin can see rather than recall. - """ - lines = [ - kv_line(slot.removeprefix("default_").removesuffix("_model"), model) - for slot, model in slots.items() - ] - print_panel("Claude Code models", lines) - - def _prompt_claude_models(state: dict) -> dict: """Build Claude's ``model_config`` one family slot at a time. @@ -463,10 +443,6 @@ def _prompt_claude_models(state: dict) -> dict: print_warning(f"No Claude models were discovered for {display} on this workspace.") return {"default_model": _require_text(f"Default model for {display}")} - print_note( - "Claude Code picks a model by family, so set a default per family. Skip any family you " - "don't want configured — it falls back to the overall default." - ) slots: dict[str, str] = {} custom: list[str] = [] for family in ANTHROPIC_FAMILIES: @@ -510,9 +486,8 @@ def _prompt_claude_models(state: dict) -> dict: model_config["default_model"] = chosen[0] print_note(f"Only one model configured, so it's {display}'s overall default.") else: - _render_family_slots(slots) model_config["default_model"] = _require_selection( - f"Which of those is {display}'s overall default?", [(m, m) for m in chosen] + "Overall Default Model", [(m, m) for m in chosen] ) if custom: model_config["custom_models"] = list(dict.fromkeys(custom)) @@ -598,10 +573,6 @@ def _prompt_claude_provider_family_models(targets: list[str], service_name: str) print_note(f"Quick setup — {summary} (default: {default_family}).") return model_config - print_note( - f"Claude Code picks a model by family, so set a default per family from {service_name}. " - "Skip any family you don't want configured — it falls back to the overall default." - ) slots: dict[str, str] = {} for family in ANTHROPIC_FAMILIES: family_targets = by_family.get(family) @@ -625,13 +596,11 @@ def _prompt_claude_provider_family_models(targets: list[str], service_name: str) model_config["default_model"] = chosen[0] print_note(f"Only one model configured, so it's {display}'s overall default.") else: - if slots: - _render_family_slots(slots) # Offered over every target, not just the slots: `default_model` needn't be a family model, # and a mixed-catalog service may expose one an admin wants as the overall default. options = chosen or list(targets) model_config["default_model"] = _require_selection( - f"Which of those is {display}'s overall default?", [(m, m) for m in options] + "Overall Default Model", [(m, m) for m in options] ) return model_config @@ -1077,8 +1046,10 @@ def _render_summary(workspace: str, manifest: dict) -> None: provider = model_config.get("model_provider_service") if provider: detail = f"{detail} via {provider}" - # Only agents that can use global settings carry the scope label; for the rest it's not a choice. - if tool in GLOBAL_SETTINGS_AGENTS: + # Older/imported manifests can still carry this field even though the interactive setup flow + # no longer authors it. Show an explicit legacy value without labelling every newly-authored + # agent "ucode-only" as though setup had asked for a scope. + if tool in GLOBAL_SETTINGS_AGENTS and "use_as_global_settings" in agent_config: scope = ( "global settings" if agent_config.get("use_as_global_settings") else "ucode-only" ) @@ -1124,7 +1095,7 @@ def _render_summary(workspace: str, manifest: dict) -> None: else: lines.append(kv_line("Tiered Spend Policy", "none")) - print_panel("Configuration summary", lines) + print_panel("Complete configuration", lines) def _config_facts(manifest: dict) -> list[tuple[str, str, str]]: @@ -1158,7 +1129,7 @@ def _config_facts(manifest: dict) -> list[tuple[str, str, str]]: facts.append((f"agent:{tool}:model:{family}", f"{display} ({family})", str(model))) elif isinstance(models, list) and len(models) > 1: facts.append((f"agent:{tool}:models", f"{display} models", ", ".join(map(str, models)))) - if tool in GLOBAL_SETTINGS_AGENTS: + if tool in GLOBAL_SETTINGS_AGENTS and "use_as_global_settings" in agent_config: scope = ( "global settings" if agent_config.get("use_as_global_settings") else "ucode-only" ) @@ -1240,7 +1211,7 @@ def _render_config_diff(existing: dict | None, incoming: dict, workspace: str) - return True -def _require_admin(workspace: str, token: str) -> None: +def _require_admin(workspace: str, token: str, *, quiet: bool = False) -> bool | None: """Stop unless the caller is a workspace admin. An unverifiable check (SCIM unreachable) warns and continues: the API enforces the same rule, so @@ -1254,12 +1225,14 @@ def _require_admin(workspace: str, token: str) -> None: "coding config, so it is restricted to workspace admins." ) if admin is None: - print_warning( - "Could not verify workspace admin permissions. Continuing — `ucode apply` will fail " - "if you lack them." - ) - else: + if not quiet: + print_warning( + "Could not verify workspace admin permissions. Continuing — `ucode apply` will " + "fail if you lack them." + ) + elif not quiet: print_success("Admin permissions verified") + return admin def _handle_existing_config(workspace: str, token: str) -> tuple[bool, dict | None]: @@ -1282,15 +1255,12 @@ def _handle_existing_config(workspace: str, token: str) -> tuple[bool, dict | No if existing is None: return True, None - print_warning( - "This workspace already has a managed configuration — one config covers every agent, MCP " - "server, skill, tracing table, and budget policy for the whole workspace." - ) + print_warning("This workspace already has a managed config.") choice = prompt_for_selection( "What would you like to do?", [ - ("create", "Author a new config (replaces the existing one when you publish)"), - ("delete", "Delete the existing config (removes it from the workspace, leaves none)"), + ("create", "Author a replacement (takes effect when published)"), + ("delete", "Delete it from the workspace"), ], ) if choice is None: @@ -1367,7 +1337,7 @@ def setup_from_file(path: str) -> int: save_managed_state(workspace, manifest) _render_summary(workspace, manifest) - print_success(f"Saved to {manifest_path.name} -> ~/.ucode/managed-state.json") + print_success(f"Configuration loaded from {manifest_path.name}") _print_next_steps(manifest) return 0 @@ -1392,30 +1362,17 @@ def _command_line(command: str, description: str, *, marker: str = " ", width: i # `ucode setup` walks these phases in order; the banners announce each one so the admin can see how # far along the flow they are, the way a multi-page form numbers its pages. -SETUP_STEP_TITLES = ["Coding agents", "Models & settings", "Default agent"] +SETUP_STEP_TITLES = [ + "Select the workspace", + "Select coding agents", + "Select models for each agent", + "Select the default agent", +] def _step_banner(index: int, title: str) -> None: - """Announce one phase of `ucode setup` as `step N of M`.""" - print_section(f"ucode setup · step {index} of {len(SETUP_STEP_TITLES)} · {title}") - - -def _selected_recap(workspace: str, enabled_agents: dict, default_agent: str | None) -> None: - """A compact panel of what's chosen so far, reprinted as the flow advances. - - Turns the run of prompts into something that reads like a form filling in: each phase reprints - the growing set of decisions before asking the next question. Agents still mid-configuration show - a `…` placeholder for their model. - """ - lines = [kv_line("Workspace", workspace)] - for tool, config in enabled_agents.items(): - model = (config.get("model_config") or {}).get("default_model") or "…" - lines.append(kv_line(TOOL_SPECS.get(tool, {}).get("display", tool), model)) - if default_agent: - lines.append( - kv_line("Default", TOOL_SPECS.get(default_agent, {}).get("display", default_agent)) - ) - print_panel("Selected so far", lines) + """Announce one phase of `ucode setup` on its vertical progress rail.""" + print_wizard_step(index, len(SETUP_STEP_TITLES), title) def _section_status_lines(manifest: dict, width: int = 0) -> list[str]: @@ -1447,6 +1404,7 @@ def _print_next_steps(manifest: dict) -> None: # would report "run `ucode setup` first". print_note("Dry run — nothing was saved. Re-run without --dry-run to author the config.") return + print_note("Local draft: [bold]~/.ucode/managed-state.json[/bold]") # These sections aren't required to publish — call them out as optional so an admin doesn't read # a config with none configured as unfinished. print_note("[dim]Optional — configure any of these, or skip straight to publishing:[/dim]") @@ -1454,7 +1412,10 @@ def _print_next_steps(manifest: dict) -> None: console.print(line) print_panel( "All done?", - ["Publish with [bold]ucode apply[/bold] so all developers use this configuration."], + [ + "Optionally test the draft with [bold]ucode --local[/bold] or " + "[bold]ucode --local[/bold]. Publish it with [bold]ucode apply[/bold]." + ], ) @@ -1524,6 +1485,7 @@ def _carry_forward_sections(previous: dict, manifest: dict) -> None: print_note(f"Rebuild it with `{rebuild}`.") +@inquirerpy_wizard def setup_command( from_file: str | None = None, *, @@ -1551,25 +1513,45 @@ def setup_command( # would be circular. from ucode.cli import _prompt_for_configuration, configure_shared_state - print_section("ucode setup") - print_note("Choose the coding agents and models for this workspace's managed config.") - print_note("Developers pull it automatically when they run ucode.") + print_wizard_header( + "ucode setup", + "Workspace-wide agent and model defaults · developers get updates automatically", + ) + _step_banner(1, SETUP_STEP_TITLES[0]) if workspace is None: - workspace, profile = _prompt_for_configuration() + workspace, profile = _prompt_for_configuration(show_section=False, prompt="Workspace:") + else: + print_note(f"Using {workspace}") # `configure_shared_state` below authenticates too and prints its own success line, so this one # stays quiet rather than reporting the same thing twice. It still has to run first: the admin # gate and the existing-config check both need a token before discovery. ensure_databricks_auth(workspace, profile, quiet=True) token = get_databricks_token(workspace, profile) - _require_admin(workspace, token) + admin_verified = _require_admin(workspace, token, quiet=True) + with spinner("Verifying Unity AI Gateway..."): + ensure_ai_gateway(workspace, token) + if admin_verified: + print_success("Workspace checks complete") + else: + print_warning( + "Workspace checks incomplete (2/3) — authentication · Unity AI Gateway; " + "permissions unverified" + ) + keep_going, published = _handle_existing_config(workspace, token) if not keep_going: + print_wizard_outro("Setup complete") return 0 # Discover the workspace's models and gateway URLs. This also logs in and persists local state. - state = configure_shared_state(workspace, profile=profile, force_login=False) + state = configure_shared_state( + workspace, + profile=profile, + force_login=False, + connection_checks_verified=True, + ) workspace = state.get("workspace") or workspace profile = state.get("profile") or profile @@ -1591,47 +1573,38 @@ def setup_command( previously_enabled = [ tool for tool in (previous.get("enabled_agents") or {}) if tool in available ] - _step_banner(1, SETUP_STEP_TITLES[0]) + _step_banner(2, SETUP_STEP_TITLES[1]) picked = prompt_for_tools( [(tool, TOOL_SPECS[tool]["display"]) for tool in available], preselected=previously_enabled or None, + prompt="Coding agents:", ) if not picked: print_note("No coding agents selected — nothing to configure.") + print_wizard_outro("No changes") return 0 - _step_banner(2, SETUP_STEP_TITLES[1]) + _step_banner(3, SETUP_STEP_TITLES[2]) enabled_agents: dict[str, dict] = {} - for index, tool in enumerate(picked, start=1): - print_heading(f"{TOOL_SPECS[tool]['display']} ({index} of {len(picked)})") + for tool in picked: + print_heading(f"Configure {TOOL_SPECS[tool]['display']}") provider_service = _select_provider_service(tool, workspace, token) # Always set: `_prompt_models_for_agent` re-prompts rather than returning empty, so every # enabled agent carries a default_model and any of them can be the default_agent. agent_config: dict = { "model_config": _prompt_models_for_agent(tool, state, provider_service) } - # Only claude and codex have an OS-level managed settings file that a bare `claude`/`codex` - # reads (`/etc/claude-code/managed-settings.json`, `/etc/codex/managed_config.toml`); the - # other agents don't, so we don't offer them the choice. - if tool in GLOBAL_SETTINGS_AGENTS: - binary = TOOL_SPECS[tool]["binary"] - agent_config["use_as_global_settings"] = prompt_yes_no_default( - f"Route `{binary}` through the gateway too, not just `ucode {binary}`? " - f"(writes {GLOBAL_SETTINGS_FILES[tool]}, needs sudo once)", - default=False, - ) enabled_agents[tool] = agent_config _confirm_agent(tool, agent_config) # Pick the default after configuring each agent, not before: by now the admin has seen every # agent's models go by, so "which is the default?" is a choice among things they've just set up - # rather than a bare list up front. The recap reprints those picks so the choice is informed. - _step_banner(3, SETUP_STEP_TITLES[2]) + # rather than a bare list up front. + _step_banner(4, SETUP_STEP_TITLES[3]) default_agent = picked[0] if len(picked) > 1: - _selected_recap(workspace, enabled_agents, default_agent=None) chosen = prompt_for_selection( - "Which coding agent should be the default?", + "Default agent:", [(tool, TOOL_SPECS[tool]["display"]) for tool in picked], ) if not chosen: @@ -1654,12 +1627,13 @@ def setup_command( print_err("The generated config is not valid:") for error in errors: print_note(error) + print_wizard_outro("Configuration could not be created", kind="error") return 1 save_managed_state(workspace, manifest) + print_wizard_outro("Setup complete") + print_success("Configuration saved") _render_summary(workspace, manifest) - console.print() - print_success("Saved to ~/.ucode/managed-state.json") _print_next_steps(manifest) _offer_apply() return 0 @@ -1727,7 +1701,7 @@ def _save_section_update(workspace: str, manifest: dict) -> int: save_managed_state(workspace, manifest) _render_summary(workspace, manifest) console.print() - print_success("Saved to ~/.ucode/managed-state.json") + print_success("Configuration saved") _print_next_steps(manifest) _offer_apply() return 0 @@ -1845,8 +1819,8 @@ def setup_help_command() -> int: """Walk through the whole managed-config setup, marking what this machine has authored. Hand-written rather than left to `--help`: the point is the *order* of the commands and the fact - that nothing reaches developers until `ucode apply`, neither of which a flag listing conveys. Reads - the manifest but never authenticates, so it works before `ucode configure`. + that nothing reaches developers until a workspace apply, neither of which a flag listing + conveys. Reads the manifest but never authenticates, so it works before `ucode configure`. """ print_section("ucode setup") print_note( @@ -1854,8 +1828,7 @@ def setup_help_command() -> int: "and get the agents, models, MCP servers, and skills you chose here. Admins only." ) print_note( - "Each command below edits your local draft; nothing reaches the workspace until " - "`ucode apply`." + "Each command below edits your local draft; nothing reaches developers until `ucode apply`." ) workspace = load_state().get("workspace") or managed_state_workspace() @@ -1864,6 +1837,7 @@ def setup_help_command() -> int: # One column width across all three groups, so the commands line up as a single list. width = max(len(command) for command, _, _ in SETUP_SECTIONS) width = max(width, len("ucode setup --from-file ")) + width = max(width, len("ucode --local")) print_heading("1. Start here") console.print( @@ -1882,11 +1856,13 @@ def setup_help_command() -> int: for line in _section_status_lines(manifest, width): console.print(line) - print_heading("3. Review and publish") + print_heading("3. Review, optionally test, and publish") console.print( _command_line("ucode setup show", "The draft, and the payload `apply` sends", width=width) ) - console.print(_command_line("ucode apply", "Publish it to the workspace", width=width)) + console.print(_command_line("ucode --local", "Test the configured default agent", width=width)) + console.print(_command_line("ucode --local", "Test a specific agent", width=width)) + console.print(_command_line("ucode apply", "Publish it for every developer", width=width)) print_heading("Also") console.print( @@ -1897,7 +1873,7 @@ def setup_help_command() -> int: ) ) print_note( - f"The draft lives in ~/.ucode/managed-state.json (workspace: {workspace or 'none'})." + f"The draft is saved at ~/.ucode/managed-state.json (workspace: {workspace or 'none'})." ) print_note( "Re-running `ucode setup` keeps the sections in step 2; to drop one, edit the draft and " @@ -2017,7 +1993,7 @@ def apply_command(*, yes: bool = False) -> int: print_err("The authored config is not valid, so it was not published:") for error in errors: print_note(error) - print_note("Re-run `ucode setup` to fix it, or edit ~/.ucode/managed-state.json.") + print_note("Re-run `ucode setup` to fix it, or edit the local managed configuration.") return 1 token = get_databricks_token(workspace, profile) diff --git a/src/ucode/ui.py b/src/ucode/ui.py index 47c85b2d..12437e87 100644 --- a/src/ucode/ui.py +++ b/src/ucode/ui.py @@ -9,10 +9,18 @@ import time from collections.abc import Callable, Iterator from contextlib import contextmanager +from contextvars import ContextVar from datetime import timedelta from decimal import ROUND_HALF_UP, Decimal +from functools import wraps +from types import MethodType +from typing import Any, TypedDict import questionary +from InquirerPy import inquirer +from InquirerPy.base.control import Choice as InquirerChoice +from InquirerPy.separator import Separator as InquirerSeparator +from InquirerPy.utils import InquirerPyStyle, get_style from prompt_toolkit.layout.containers import Window from prompt_toolkit.layout.dimension import Dimension from questionary.prompts.common import InquirerControl @@ -24,6 +32,136 @@ console = Console(highlight=False) err_console = Console(stderr=True, highlight=False) +# `ucode setup` opts into InquirerPy while the rest of the CLI stays on Questionary. Context-local +# state keeps nested helpers on the same backend without threading a presentation flag through every +# model/provider/budget function, and won't bleed between concurrent callers or tests. +_prompt_backend: ContextVar[str] = ContextVar("ucode_prompt_backend", default="questionary") +_wizard_rail_open: ContextVar[bool] = ContextVar("ucode_wizard_rail_open", default=False) + + +def _using_inquirerpy() -> bool: + return _prompt_backend.get() == "inquirerpy" + + +def _rail_prefix() -> str: + return "[dim]│[/dim] " if _wizard_rail_open.get() else "" + + +def _print_blank(*, stderr: bool = False) -> None: + target = err_console if stderr else console + target.print("[dim]│[/dim]" if _wizard_rail_open.get() else "") + + +def inquirerpy_wizard[**P, R](func: Callable[P, R]) -> Callable[P, R]: + """Run one wizard on InquirerPy and reset all terminal-layout state afterwards.""" + + @wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> R: + backend_token = _prompt_backend.set("inquirerpy") + rail_token = _wizard_rail_open.set(False) + try: + return func(*args, **kwargs) + except KeyboardInterrupt: + if _wizard_rail_open.get(): + print_wizard_outro("Setup interrupted", kind="warning") + raise + except Exception: + if _wizard_rail_open.get(): + print_wizard_outro("Setup stopped", kind="error") + raise + finally: + _wizard_rail_open.reset(rail_token) + _prompt_backend.reset(backend_token) + + return wrapped + + +_INQUIRER_STYLE = get_style( + { + "questionmark": "#666666", + "answermark": "#666666", + "answer": "#00d7d7 bold", + "input": "#00d7d7", + "question": "bold", + "answered_question": "bold", + "instruction": "#666666", + "long_instruction": "#666666", + "pointer": "#00d7d7 bold", + "checkbox": "#00d7d7", + "separator": "#8a8a8a bold", + "marker": "#00d7d7", + "fuzzy_prompt": "#00d7d7", + "fuzzy_info": "#666666", + "fuzzy_match": "#00d7d7 bold", + }, + style_override=False, +) + + +def _inquirer_marks() -> tuple[str, str]: + # InquirerPy inserts one space after the mark. Include one more while the wizard rail is open so + # question text aligns with Rich output's ``│ `` gutter. + return ("│ ", "│ ") if _wizard_rail_open.get() else ("◇", "◆") + + +class _InquirerCommon(TypedDict): + style: InquirerPyStyle + qmark: str + amark: str + raise_keyboard_interrupt: bool + + +def _inquirer_common() -> _InquirerCommon: + qmark, amark = _inquirer_marks() + return { + "style": _INQUIRER_STYLE, + "qmark": qmark, + "amark": amark, + "raise_keyboard_interrupt": True, + } + + +def _with_inquirer_rail(question: Any) -> Any: + """Prefix every row in an InquirerPy choice list with the wizard's live rail. + + InquirerPy applies ``qmark`` only to the question. Its option window starts back at column zero, + which both breaks the vertical rail and makes the highlighted row look less indented than status + output. Wrapping the control's two row formatters keeps highlighted, normal, and separator rows + under the same ``│ `` gutter without changing their values or key handling. + + Test doubles and non-list prompts have no ``content_control`` and pass through unchanged. + """ + if not _wizard_rail_open.get(): + return question + control = getattr(question, "content_control", None) + if control is None: + return question + + original_hover = control._get_hover_text + original_normal = control._get_normal_text + + def hover(_: Any, choice: Any) -> list[tuple[str, str]]: + return [("class:questionmark", "│ "), *original_hover(choice)] + + def normal(_: Any, choice: Any) -> list[tuple[str, str]]: + return [("class:questionmark", "│ "), *original_normal(choice)] + + control._get_hover_text = MethodType(hover, control) + control._get_normal_text = MethodType(normal, control) + return question + + +def _inquirer_filter_prompt() -> str: + return "│ Filter" if _wizard_rail_open.get() else "Filter" + + +def _choice_summary(values: list[str], labels: dict[str, str]) -> str: + selected = [labels.get(str(value), str(value)) for value in values] + if len(selected) <= 3: + return ", ".join(selected) + return f"{len(selected)} selected" + + # Past this many options the choice list is pinned to a fixed-height scrolling viewport (see # `_cap_choice_viewport`) rather than growing to fill the terminal, and the pickers append a # "↑/↓ scroll" note to their instruction line. The value is both the boundary and the number of @@ -91,13 +229,53 @@ def print_section(title: str) -> None: console.print(Panel(title, style="bold blue", expand=False)) -def print_heading(text: str) -> None: +def print_wizard_header(command: str, description: str) -> None: + """Open an interactive flow with compact command branding and a continuous rail. + + A wizard is one continuous composition, so a panel around its name makes the following steps + look like separate cards. Styling the command itself gives the flow an identity while leaving + the rest of the terminal as one canvas. + """ + head, separator, tail = command.partition(" ") + rendered = f"[bold cyan]{escape(head)}[/bold cyan]" + if separator: + rendered += f" [bold]{escape(tail)}[/bold]" console.print() - console.print(f"[bold]{text}[/bold]") + console.print(f"[bold cyan]┌[/bold cyan] {rendered}") + console.print(f"[dim]│[/dim] [dim]{escape(description)}[/dim]") + _wizard_rail_open.set(True) + + +def print_wizard_step(index: int, total: int, title: str) -> None: + """Render one node in a compact vertical stepper. + + The connector is printed immediately before every node after the first. Output and prompts from + the previous phase remain between the two nodes, so the rail visually joins the whole flow + without requiring a full-screen TUI or redrawing terminal history. + """ + console.print("[dim]│[/dim]") + console.print( + f"[bold cyan]◇[/bold cyan] [dim]{index}/{total}[/dim] [bold]{escape(title)}[/bold]" + ) + + +def print_wizard_outro(message: str, *, kind: str = "success") -> None: + """Close the active wizard rail and return subsequent output to the normal left margin.""" + if not _wizard_rail_open.get(): + return + color = {"success": "green", "warning": "yellow", "error": "red"}.get(kind, "cyan") + console.print("[dim]│[/dim]") + console.print(f"[bold {color}]└[/bold {color}] [bold]{escape(message)}[/bold]") + _wizard_rail_open.set(False) + + +def print_heading(text: str) -> None: + _print_blank() + console.print(f"{_rail_prefix()}[bold]{text}[/bold]") def print_kv(key: str, val: str) -> None: - console.print(f" [bold]{key}:[/bold] [cyan]{val}[/cyan]") + console.print(f"{_rail_prefix()}[bold]{key}:[/bold] [cyan]{val}[/cyan]") def kv_line(key: str, val: str) -> str: @@ -118,6 +296,11 @@ def print_panel(title: str, lines: list[str]) -> None: should be read as one unit (a config summary an admin is about to publish) reads as one, rather than as loose lines that blend into whatever the flow printed before it. """ + if _wizard_rail_open.get(): + print_heading(title) + for line in lines: + console.print(f"{_rail_prefix()}{line}") + return console.print() console.print(Panel("\n".join(lines), title=title, style="blue", expand=False)) @@ -129,6 +312,10 @@ def print_warning_panel(message: str, *, title: str = "Warning") -> None: dead-end message (e.g. "no budgets exist, nothing to do") the weight to be read before the flow exits, rather than scrolling past as one more line. """ + if _wizard_rail_open.get(): + print_heading(title) + console.print(f"{_rail_prefix()}[bold yellow]![/bold yellow] {message}") + return console.print() console.print( Panel(f"[bold yellow]![/bold yellow] {message}", title=title, style="yellow", expand=False) @@ -136,24 +323,24 @@ def print_warning_panel(message: str, *, title: str = "Warning") -> None: def print_note(text: str) -> None: - console.print(f"[dim]•[/dim] {text}") + console.print(f"{_rail_prefix()}[dim]•[/dim] {text}") def print_success(message: str) -> None: - console.print(f"[bold green]✔[/bold green] {message}") + console.print(f"{_rail_prefix()}[bold green]✔[/bold green] {message}") def print_warning(message: str) -> None: - console.print(f"[bold yellow]![/bold yellow] {message}") + console.print(f"{_rail_prefix()}[bold yellow]![/bold yellow] {message}") def print_warning_err(message: str) -> None: """``print_warning`` on stderr, for when stdout is a machine-read stream.""" - err_console.print(f"[bold yellow]![/bold yellow] {message}") + err_console.print(f"{_rail_prefix()}[bold yellow]![/bold yellow] {message}") def print_err(message: str) -> None: - err_console.print(f"[bold red]ERROR[/bold red] {message}") + err_console.print(f"{_rail_prefix()}[bold red]ERROR[/bold red] {message}") def heading(text: str) -> str: @@ -195,6 +382,9 @@ def current_message() -> str: current_message = message stop_event = threading.Event() + # Context variables don't propagate into this animation thread, so capture the wizard gutter + # now; otherwise the spinner is the one line that jumps outside an otherwise continuous rail. + spinner_prefix = "\033[2m│\033[0m " if _wizard_rail_open.get() else "" def spin() -> None: for frame in itertools.cycle("|/-\\"): @@ -202,7 +392,7 @@ def spin() -> None: break # `\033[K` erases to end of line so a shrinking dynamic message # doesn't leave stale characters behind. - sys.stdout.write(f"\r\033[2m{frame}\033[0m {current_message()}\033[K") + sys.stdout.write(f"\r{spinner_prefix}\033[2m{frame}\033[0m {current_message()}\033[K") sys.stdout.flush() time.sleep(0.1) sys.stdout.write("\r\033[K") @@ -344,65 +534,109 @@ def normalize_workspace_url(workspace: str) -> str: def prompt_for_workspace( description: str, profiles: list[tuple[str, str]] | None = None, + *, + show_section: bool = True, + prompt: str = "Select workspace:", ) -> tuple[str, str | None]: """Ask the user for a workspace URL, offering profiles as quick-select. `profiles` is a list of (host_url, profile_name) tuples. Caller fetches them — `ui.py` stays Databricks-agnostic. Duplicate hosts (multiple profiles pointing at the same workspace) are shown separately; the picker - returns the exact (host, profile_name) the user selected. Returns - ``(url, profile_name)``; profile_name is ``None`` when the user typed a - URL manually. + returns the exact (host, profile_name) the user selected. ``show_section=False`` lets a larger + wizard supply its own step banner. Returns ``(url, profile_name)``; profile_name is ``None`` + when the user typed a URL manually. """ - console.print() - console.print(Panel(description, title="ucode setup", style="bold blue", expand=False)) + # Keep section labels visually consistent with every other CLI phase: the label is the panel + # body, not a border title. Callers that already printed a larger flow's step banner can suppress + # this local section rather than showing two boxes back to back. + if show_section: + print_section(description) if profiles: name_header = "Profile Name" url_header = "Workspace URL" - # Clamp so a single very long profile name can't push the URL column - # off-screen on an 80-col terminal — questionary doesn't wrap row - # titles cleanly, and a wrapped row breaks the picker visually. + # Clamp so a single very long profile name can't push the URL column off-screen. max_name_width = 40 name_width = min( max_name_width, max(len(name_header), *(len(name) for _, name in profiles)), ) - # Match the 2-char cursor gutter so the header line aligns with rows. header_title = f" {name_header.ljust(name_width)} {url_header}" - choices: list[questionary.Choice | questionary.Separator] = [ - questionary.Separator(header_title) - ] - for host, profile_name in profiles: + + def profile_row(host: str, profile_name: str) -> str: display_name = ( profile_name if len(profile_name) <= name_width else profile_name[: name_width - 1] + "…" ) - row_title = f"{display_name.ljust(name_width)} {host}" - # Value carries the full untruncated profile name so downstream - # `--profile` calls always use the real name, not the display form. - choices.append(questionary.Choice(title=row_title, value=(host, profile_name))) - choices.append(questionary.Choice(title="Enter a different URL", value=None)) - style = questionary.Style( - [ - ("highlighted", "fg:cyan bold"), - ("pointer", "fg:cyan bold"), - ("answer", "fg:cyan"), - ("separator", "fg:white bold"), + return f"{display_name.ljust(name_width)} {host}" + + if _using_inquirerpy(): + inquirer_choices: list[InquirerChoice | InquirerSeparator] = [ + InquirerSeparator(header_title), + *[ + InquirerChoice(value=(host, profile_name), name=profile_row(host, profile_name)) + for host, profile_name in profiles + ], + InquirerChoice(value=None, name="Enter a different URL"), ] - ) - choice = questionary.select( - "Select workspace:", choices=choices, style=style, pointer="›", qmark="" - ).ask() + + def workspace_summary(answer: object) -> str: + # InquirerPy passes the selected row's display name to transformers, not its raw + # value. A tuple check therefore mislabeled every completed profile selection as + # "Enter a different URL". Preserve the actual profile-and-workspace display row. + return str(answer) + + question = inquirer.select( + message=prompt, + choices=inquirer_choices, + pointer="❯", + instruction="↑↓ move · enter select", + transformer=workspace_summary, + max_height=_SCROLL_HINT_THRESHOLD, + **_inquirer_common(), + ) + choice = _with_inquirer_rail(question).execute() + else: + choices: list[questionary.Choice | questionary.Separator] = [ + questionary.Separator(header_title), + *[ + questionary.Choice( + title=profile_row(host, profile_name), value=(host, profile_name) + ) + for host, profile_name in profiles + ], + questionary.Choice(title="Enter a different URL", value=None), + ] + style = questionary.Style( + [ + ("highlighted", "fg:cyan bold"), + ("pointer", "fg:cyan bold"), + ("answer", "fg:cyan"), + ("separator", "fg:white bold"), + ] + ) + choice = questionary.select( + prompt, choices=choices, style=style, pointer="›", qmark="" + ).ask() if isinstance(choice, tuple): host, profile_name = choice return normalize_workspace_url(host), profile_name while True: - raw_value = console.input(f" [bold]Workspace URL[/bold] {muted('›')} ").strip() + if _using_inquirerpy(): + raw_value = inquirer.text( + message=prompt, + validate=lambda value: bool(str(value).strip()), + invalid_message="Enter a workspace URL", + mandatory=True, + **_inquirer_common(), + ).execute() + else: + raw_value = console.input(f" [bold]Workspace URL[/bold] {muted('›')} ").strip() try: - return normalize_workspace_url(raw_value), None + return normalize_workspace_url(str(raw_value)), None except ValueError as exc: print_err(str(exc)) @@ -420,6 +654,32 @@ def prompt_for_tools( agents an existing managed config already enables). Returns [] if the user submits an empty selection. """ + preselected_set = {str(item) for item in preselected} if preselected is not None else None + if _using_inquirerpy(): + labels = dict(available) + choices = [ + InquirerChoice( + value=tool_id, + name=display, + enabled=(preselected_set is None or tool_id in preselected_set), + ) + for tool_id, display in available + ] + question = inquirer.checkbox( + message=prompt, + choices=choices, + pointer="❯", + enabled_symbol="◉", + disabled_symbol="○", + instruction="↑↓ move · space toggle · enter confirm", + transformer=lambda values: _choice_summary(values, labels), + max_height=_SCROLL_HINT_THRESHOLD, + mandatory=False, + **_inquirer_common(), + ) + answer = _with_inquirer_rail(question).execute() + return list(answer or []) + style = questionary.Style( [ # Theme-agnostic picker: every row renders in the terminal's @@ -432,7 +692,6 @@ def prompt_for_tools( ("answer", "fg:cyan"), ] ) - preselected_set = {str(item) for item in preselected} if preselected is not None else None choices = [ questionary.Choice( title=display, @@ -470,6 +729,52 @@ def prompt_for_multi_selection( ``searchable`` lets the user narrow a long list by typing; see :func:`prompt_for_selection` for why it trades away j/k navigation. """ + preselected_set = {str(item) for item in preselected} if preselected is not None else set() + if _using_inquirerpy(): + labels = dict(options) + choices = [ + InquirerChoice( + value=value, + name=option_label, + enabled=value in preselected_set, + ) + for value, option_label in options + ] + # Fuzzy search owns a separate input row. On a short list that row looks like a blank first + # choice with a second cursor, so reserve it for lists long enough to benefit from filtering. + if searchable and len(options) > _SCROLL_HINT_THRESHOLD: + question = inquirer.fuzzy( + message=prompt, + choices=choices, + pointer="❯", + transformer=lambda values: _choice_summary(values, labels), + max_height=_SCROLL_HINT_THRESHOLD, + mandatory=False, + multiselect=True, + prompt=_inquirer_filter_prompt(), + marker="◉", + marker_pl="○", + info=False, + instruction="type filter · space toggle · enter confirm", + keybindings={"toggle": [{"key": "space"}]}, + **_inquirer_common(), + ) + else: + question = inquirer.checkbox( + message=prompt, + choices=choices, + pointer="❯", + transformer=lambda values: _choice_summary(values, labels), + max_height=_SCROLL_HINT_THRESHOLD, + mandatory=False, + enabled_symbol="◉", + disabled_symbol="○", + instruction="↑↓ move · space toggle · enter confirm", + **_inquirer_common(), + ) + answer = _with_inquirer_rail(question).execute() + return None if answer is None else list(answer) + style = questionary.Style( [ ("pointer", "fg:cyan bold"), @@ -478,7 +783,6 @@ def prompt_for_multi_selection( ("answer", "fg:cyan"), ] ) - preselected_set = {str(item) for item in preselected} if preselected is not None else set() choices = [ questionary.Choice(title=option_label, value=value, checked=value in preselected_set) for value, option_label in options @@ -524,6 +828,18 @@ def prompt_for_text( word-like default vanished from the prompt entirely — numeric ones like ``[80]`` are not valid tags and survived, which is why this looked fine wherever it was checked. """ + if _using_inquirerpy(): + answer = inquirer.text( + message=prompt, + default=default or "", + instruction="enter to accept current value" if default else "", + validate=lambda value: bool(str(value).strip()), + invalid_message="Enter a value", + mandatory=True, + **_inquirer_common(), + ).execute() + return str(answer).strip() if answer is not None else default + hint = f" {escape(f'[{default}]')} (enter to accept)" if default else "" while True: try: @@ -553,6 +869,28 @@ def prompt_for_percentage(prompt: str, *, default: float | None = None) -> float Raises ``KeyboardInterrupt`` on closed stdin when there is no default — see the handler below. """ + if _using_inquirerpy(): + default_percent = f"{default * 100:g}" if default is not None else "" + + def valid_percentage(value: str) -> bool: + try: + percent = float(str(value).rstrip("%")) + except ValueError: + return False + return 0 <= percent <= 100 + + answer = inquirer.text( + message=prompt, + default=default_percent, + instruction="0–100", + validate=valid_percentage, + invalid_message="Enter a number between 0 and 100", + transformer=lambda value: f"{float(str(value).rstrip('%')):g}%", + mandatory=True, + **_inquirer_common(), + ).execute() + return float(str(answer).rstrip("%")) / 100 + hint = f" {escape(f'[{default * 100:g}]')} (enter to accept)" if default is not None else "" while True: try: @@ -589,6 +927,37 @@ def prompt_for_selection( rejects both at once, since j and k are also search characters — so it is opt-in for the pickers that are actually long (model and budget lists), leaving short ones on plain arrow keys. """ + if _using_inquirerpy(): + labels = dict(options) + choices = [ + InquirerChoice(value=value, name=option_label) for value, option_label in options + ] + # InquirerPy's fuzzy control always adds a search-input row. Use it only when the list + # actually needs filtering; otherwise that empty row reads as a duplicate selection cursor. + if searchable and len(options) > _SCROLL_HINT_THRESHOLD: + question = inquirer.fuzzy( + message=prompt, + choices=choices, + pointer="❯", + transformer=lambda selected: labels.get(str(selected), str(selected)), + max_height=_SCROLL_HINT_THRESHOLD, + prompt=_inquirer_filter_prompt(), + info=False, + instruction="type filter · ↑↓ move · enter select", + **_inquirer_common(), + ) + else: + question = inquirer.select( + message=prompt, + choices=choices, + pointer="❯", + transformer=lambda selected: labels.get(str(selected), str(selected)), + max_height=_SCROLL_HINT_THRESHOLD, + instruction="↑↓ move · enter select", + **_inquirer_common(), + ) + return _with_inquirer_rail(question).execute() + style = questionary.Style( [ ("pointer", "fg:cyan bold"), @@ -627,6 +996,19 @@ def prompt_yes_no(prompt: str) -> bool: def prompt_yes_no_default(prompt: str, *, default: bool) -> bool: """Empty answer or closed stdin (EOF) takes ``default`` (no abort on piped runs).""" + if _using_inquirerpy(): + default_label = "Yes" if default else "No" + keys = "Y/n" if default else "y/N" + return bool( + inquirer.confirm( + message=prompt, + default=default, + instruction=f"{keys} · enter selects {default_label}", + transformer=lambda answer: "Yes" if answer else "No", + **_inquirer_common(), + ).execute() + ) + hint = "(Y/n)" if default else "(y/N)" while True: try: diff --git a/tests/test_cli.py b/tests/test_cli.py index ad45a9a7..17a69431 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1984,6 +1984,33 @@ def test_reconfigure_without_flag_clears_use_pat(self, monkeypatch): assert logins == [(self.WS, "DEFAULT")] assert "use_pat" not in state + def test_verified_connection_skips_duplicate_auth_and_gateway_checks(self, monkeypatch): + cli_mod, logins, ensures, _ = self._stub_deps(monkeypatch, pat_token="dapi-pat") + gateway_checks: list[tuple[str, str]] = [] + token_fetches: list[tuple[str, str | None]] = [] + monkeypatch.setattr( + cli_mod, + "get_databricks_token", + lambda workspace, profile: token_fetches.append((workspace, profile)) or "token", + ) + monkeypatch.setattr( + cli_mod, + "ensure_ai_gateway", + lambda workspace, token: gateway_checks.append((workspace, token)), + ) + + cli_mod.configure_shared_state( + self.WS, + profile="DEFAULT", + skip_model_discovery=True, + connection_checks_verified=True, + ) + + assert logins == [] + assert ensures == [] + assert gateway_checks == [] + assert token_fetches == [(self.WS, "DEFAULT")] + def test_uc_models_used_without_legacy_fallback(self, monkeypatch): # When model-services returns models, they're used and the legacy # per-family discovery is never consulted. @@ -2414,6 +2441,149 @@ def test_absent_flag_defaults_false(self, tool): assert cfg.call_args.kwargs["skip_preflight"] is False +class TestLocalManagedConfigFlag: + LAUNCH_TOOLS = ["codex", "claude", "gemini", "opencode", "copilot", "pi"] + + @pytest.mark.parametrize("tool", LAUNCH_TOOLS) + def test_launch_flag_is_forwarded_for_every_managed_agent(self, tool): + with patch("ucode.cli._launch_tool") as launch: + result = runner.invoke(app, [tool, "--local"]) + + assert result.exit_code == 0, result.output + assert launch.call_args.kwargs["local_config"] is True + + def test_absent_flag_keeps_the_workspace_source(self): + with patch("ucode.cli._launch_tool") as launch: + result = runner.invoke(app, ["claude"]) + + assert result.exit_code == 0, result.output + assert launch.call_args.kwargs["local_config"] is False + + def test_help_identifies_local_as_admin_only(self): + result = runner.invoke(app, ["claude", "--help"]) + + assert result.exit_code == 0, result.output + assert "Workspace admins only" in _strip_ansi(result.output) + + @pytest.mark.parametrize( + "args", + [ + ["--skip-managed-config"], + ["claude", "--skip-managed-config"], + ["configure", "--skip-managed-config"], + ], + ) + def test_removed_skip_managed_config_is_rejected(self, args): + with patch("ucode.cli._launch_tool") as launch: + result = runner.invoke(app, args) + + assert result.exit_code == 2 + assert "has been removed" in result.output + launch.assert_not_called() + + def test_local_launch_reads_the_draft_and_never_fetches_workspace_config(self): + authored = { + "enabled_agents": { + "claude": {"model_config": {"default_model": "databricks-claude-sonnet-4"}} + } + } + fetch = MagicMock(side_effect=AssertionError("--local must not fetch the workspace config")) + with ( + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch("ucode.cli.load_state", return_value=MINIMAL_STATE), + patch("ucode.cli.ensure_provider_state", return_value=MINIMAL_STATE), + patch("ucode.cli.load_managed_state", return_value=authored) as load_draft, + patch("ucode.cli._require_local_config_admin") as require_admin, + patch("ucode.cli._fetch_managed_config", fetch), + patch("ucode.cli.configure_shared_state", return_value=MINIMAL_STATE), + patch( + "ucode.cli.resolve_launch_model", + return_value=(MINIMAL_STATE, "databricks-claude-sonnet-4"), + ), + patch("ucode.cli.configure_tool", return_value=MINIMAL_STATE), + patch("ucode.cli._register_managed_mcp_servers"), + patch("ucode.cli._apply_managed_skills"), + patch("ucode.cli.launch_agent"), + ): + result = runner.invoke(app, ["claude", "--local"]) + + assert result.exit_code == 0, result.output + load_draft.assert_called_once_with(MINIMAL_STATE["workspace"]) + require_admin.assert_called_once_with(MINIMAL_STATE) + fetch.assert_not_called() + assert "local managed coding agent config" in result.output + + def test_specific_local_launch_without_a_draft_is_actionable(self): + fetch = MagicMock(side_effect=AssertionError("--local must not fetch the workspace config")) + require_admin = MagicMock() + with ( + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch("ucode.cli.load_state", return_value=MINIMAL_STATE), + patch("ucode.cli.ensure_provider_state", return_value=MINIMAL_STATE), + patch("ucode.cli.load_managed_state", return_value=None), + patch("ucode.cli._require_local_config_admin", require_admin), + patch("ucode.cli._fetch_managed_config", fetch), + ): + result = runner.invoke(app, ["claude", "--local"]) + + assert result.exit_code == 1 + assert "setup" in result.output + require_admin.assert_not_called() + fetch.assert_not_called() + + def test_non_admin_cannot_launch_a_local_draft(self): + authored = {"enabled_agents": {"claude": {}}} + launch = MagicMock() + with ( + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch("ucode.cli.load_state", return_value=MINIMAL_STATE), + patch("ucode.cli.ensure_provider_state", return_value=MINIMAL_STATE), + patch("ucode.cli.load_managed_state", return_value=authored), + patch("ucode.cli.get_databricks_token", return_value="token"), + patch("ucode.cli.is_workspace_admin", return_value=False), + patch("ucode.cli.launch_agent", launch), + ): + result = runner.invoke(app, ["claude", "--local"]) + + assert result.exit_code == 1 + assert "not an admin" in result.output + launch.assert_not_called() + + +class TestRequireLocalConfigAdmin: + def test_verified_admin_is_allowed(self): + with ( + patch("ucode.cli.get_databricks_token", return_value="token") as get_token, + patch("ucode.cli.is_workspace_admin", return_value=True) as is_admin, + ): + import ucode.cli as cli_mod + + cli_mod._require_local_config_admin(MINIMAL_STATE) + + get_token.assert_called_once_with(MINIMAL_STATE["workspace"], None) + is_admin.assert_called_once_with(MINIMAL_STATE["workspace"], "token") + + @pytest.mark.parametrize("admin", [False, None]) + def test_non_admin_or_unverifiable_status_is_blocked(self, admin): + with ( + patch("ucode.cli.get_databricks_token", return_value="token"), + patch("ucode.cli.is_workspace_admin", return_value=admin), + ): + import ucode.cli as cli_mod + + with pytest.raises(RuntimeError, match="admin"): + cli_mod._require_local_config_admin(MINIMAL_STATE) + + def test_authentication_failure_is_blocked_with_actionable_error(self): + with patch( + "ucode.cli.get_databricks_token", side_effect=RuntimeError("credentials expired") + ): + import ucode.cli as cli_mod + + with pytest.raises(RuntimeError, match="authentication.*retry"): + cli_mod._require_local_config_admin(MINIMAL_STATE) + + class TestRejectDisabledAgent: """`enabled_agents` is an allowlist: an agent the admin didn't enable would launch unmanaged.""" @@ -2468,18 +2638,6 @@ def test_disabled_reads_nothing_at_all(self, monkeypatch, env_value): ) assert self._fetch({"workspace": "https://w"}) == (None, False) - def test_skip_managed_config_makes_the_fetch_a_no_op(self, monkeypatch): - # --skip-managed-config clears the enabling env var, so the read behaves as feature-off: - # no fetch, no cache read, no network — just None. - monkeypatch.setenv("ENABLE_MANAGED_AGENT_CONFIG", "1") - monkeypatch.setattr( - "ucode.cli.refresh_managed_config", lambda state: pytest.fail("should not fetch") - ) - import ucode.cli as cli_mod - - cli_mod._disable_managed_config_if_requested(True) - assert self._fetch({"workspace": "https://w"}) == (None, False) - class TestManagedConfigDecidesDiscoveryFromFreshRead: def test_a_removed_model_list_no_longer_skips_discovery(self, monkeypatch): @@ -2494,7 +2652,7 @@ def test_a_removed_model_list_no_longer_skips_discovery(self, monkeypatch): "enabled_agents": {"claude": {"model_config": {"models": {"default_opus_model": "m"}}}} } fresh = {"enabled_agents": {"claude": {"model_config": {}}}} - monkeypatch.setattr("ucode.cli.load_managed_state", lambda ws: stale_cache) + monkeypatch.setattr("ucode.cli.load_managed_cache", lambda ws: stale_cache) monkeypatch.setattr("ucode.cli.refresh_managed_config", lambda state: (fresh, False)) state = dict(MINIMAL_STATE) @@ -2761,7 +2919,6 @@ def _run( managed, is_admin=False, args=None, - cached=None, coding_agent_config_feature_disabled=False, ): launched: list[tuple] = [] @@ -2775,7 +2932,6 @@ def _run( else: monkeypatch.setattr("ucode.cli.refresh_managed_config", lambda state: (managed, False)) - monkeypatch.setattr("ucode.cli.load_managed_state", lambda ws: cached) monkeypatch.setattr("ucode.cli.get_databricks_token", lambda *a, **k: "tok") monkeypatch.setattr("ucode.cli.is_workspace_admin", lambda *a, **k: is_admin) monkeypatch.setattr( @@ -2792,6 +2948,47 @@ def test_launches_the_managed_default_agent(self, monkeypatch): assert "paved" not in result.output # no policy set in this config assert "Claude Code" in result.output + def test_local_flag_launches_the_authored_default_without_the_feature_flag(self, monkeypatch): + monkeypatch.delenv("ENABLE_MANAGED_AGENT_CONFIG", raising=False) + monkeypatch.setattr("ucode.cli.install_databricks_cli", lambda *a, **k: None) + monkeypatch.setattr("ucode.cli.apply_pat_environment", lambda *a, **k: None) + monkeypatch.setattr("ucode.cli.load_state", lambda: {"workspace": "https://w"}) + monkeypatch.setattr("ucode.cli.load_managed_state", lambda ws: self.MANAGED) + require_admin = MagicMock() + monkeypatch.setattr("ucode.cli._require_local_config_admin", require_admin) + monkeypatch.setattr( + "ucode.cli.refresh_managed_config", + lambda state: pytest.fail("--local must not fetch the workspace config"), + ) + launched: list[tuple] = [] + monkeypatch.setattr( + "ucode.cli._launch_tool", lambda tool, ctx, **kw: launched.append((tool, kw)) + ) + + result = runner.invoke(app, ["--local"]) + + assert result.exit_code == 0, result.output + assert launched[0][0] == "claude" + assert launched[0][1]["managed"] == self.MANAGED + assert launched[0][1]["local_config"] is True + assert launched[0][1]["local_admin_verified"] is True + require_admin.assert_called_once_with({"workspace": "https://w"}) + + def test_local_flag_without_an_authored_config_is_actionable(self, monkeypatch): + monkeypatch.delenv("ENABLE_MANAGED_AGENT_CONFIG", raising=False) + monkeypatch.setattr("ucode.cli.install_databricks_cli", lambda *a, **k: None) + monkeypatch.setattr("ucode.cli.apply_pat_environment", lambda *a, **k: None) + monkeypatch.setattr("ucode.cli.load_state", lambda: {"workspace": "https://w"}) + monkeypatch.setattr("ucode.cli.load_managed_state", lambda ws: None) + require_admin = MagicMock() + monkeypatch.setattr("ucode.cli._require_local_config_admin", require_admin) + + result = runner.invoke(app, ["--local"]) + + assert result.exit_code == 1 + assert "setup" in result.output + require_admin.assert_not_called() + def test_falls_back_to_the_first_enabled_agent(self, monkeypatch): managed = {"enabled_agents": {"opencode": {}}} result, launched = self._run(monkeypatch, managed=managed) @@ -2835,7 +3032,7 @@ def test_dry_run_uses_the_cache_and_does_not_fetch(self, monkeypatch): "ucode.cli.refresh_managed_config", lambda state: pytest.fail("--dry-run must not fetch"), ) - monkeypatch.setattr("ucode.cli.load_managed_state", lambda ws: self.MANAGED) + monkeypatch.setattr("ucode.cli.load_managed_cache", lambda ws: self.MANAGED) launched: list[tuple] = [] monkeypatch.setattr( "ucode.cli._launch_tool", lambda tool, ctx, **kw: launched.append((tool, kw)) @@ -2869,18 +3066,6 @@ def test_skip_preflight_still_resolves_an_agent_from_the_managed_config(self, mo assert seen["tool"] == "claude" assert seen["skip_preflight"] is True - def test_skip_managed_config_behaves_as_feature_off(self, monkeypatch): - # --skip-managed-config clears the enabling env var, so bare `ucode` has no config to pick an - # agent from and just prints help — exactly the feature-off behavior, no fetch. - monkeypatch.setenv("ENABLE_MANAGED_AGENT_CONFIG", "1") - monkeypatch.setattr( - "ucode.cli.refresh_managed_config", - lambda state: pytest.fail("--skip-managed-config must not fetch"), - ) - result = runner.invoke(app, ["--skip-managed-config"]) - assert result.exit_code == 0, result.output - assert "Usage:" in result.output - @pytest.mark.parametrize("env_value", [None, "", "0"]) def test_prints_help_when_the_env_var_is_off(self, monkeypatch, env_value): if env_value is None: @@ -2906,29 +3091,6 @@ def test_subcommands_still_work(self, monkeypatch): result = runner.invoke(app, ["status"]) assert result.exit_code == 0, result.output - def test_launcher_skip_managed_config_does_not_fetch(self, monkeypatch): - # `ucode claude --skip-managed-config` clears the env var, so the launch never reads the - # workspace's managed config and falls back to the developer's own settings. - monkeypatch.setenv("ENABLE_MANAGED_AGENT_CONFIG", "1") - monkeypatch.setattr( - "ucode.cli.refresh_managed_config", - lambda state: pytest.fail("--skip-managed-config must not fetch"), - ) - state = dict(MINIMAL_STATE) - with ( - patch("ucode.cli.load_state", return_value=state), - patch("ucode.cli.apply_pat_environment"), - patch("ucode.cli.ensure_bootstrap_dependencies"), - patch("ucode.cli.ensure_provider_state", return_value=state), - patch("ucode.cli.configure_shared_state", return_value=state), - patch("ucode.cli.configure_tool", return_value=state), - patch("ucode.cli.get_databricks_token", return_value="tok"), - patch("ucode.cli.launch_agent"), - ): - result = runner.invoke(app, ["claude", "--skip-managed-config"]) - assert result.exit_code == 0, result.output - assert "managed coding agent config" not in result.output - class TestBudgetRecommendationAtLaunch: """The budget read informs the launch; it never blocks it.""" diff --git a/tests/test_managed_config.py b/tests/test_managed_config.py index 8115c47c..45429ad6 100644 --- a/tests/test_managed_config.py +++ b/tests/test_managed_config.py @@ -13,10 +13,12 @@ import ucode.managed_config as mc_mod from ucode.managed_config import ( get_managed_config, + load_managed_cache, load_managed_state, managed_state_workspace, normalize_managed_config, refresh_managed_config, + save_managed_cache, save_managed_state, ) from ucode.managed_setup import serialize_managed_config @@ -195,6 +197,10 @@ class TestPersistence: @pytest.fixture(autouse=True) def _managed_path(self, tmp_path, monkeypatch): path = tmp_path / ".ucode" / "managed-state.json" + monkeypatch.setattr(mc_mod, "MANAGED_CACHE_DIR", tmp_path / ".ucode" / "managed-cache") + monkeypatch.setattr( + mc_mod, "LEGACY_MANAGED_CACHE_PATH", tmp_path / ".ucode" / "managed-cache.json" + ) monkeypatch.setattr(mc_mod, "MANAGED_STATE_PATH", path) return path @@ -204,12 +210,75 @@ def test_save_then_load_round_trips(self, _managed_path): loaded = load_managed_state("https://ws.example.com") assert loaded == cfg + def test_schema_version_is_the_first_field(self, _managed_path): + save_managed_state("https://ws.example.com", {"default_agent": "claude"}) + payload = json.loads(_managed_path.read_text(encoding="utf-8")) + assert list(payload) == ["schema_version", "workspace", "config"] + assert payload["schema_version"] == "1.0" + + def test_v1_schema_is_read(self, _managed_path): + _managed_path.parent.mkdir(parents=True, exist_ok=True) + _managed_path.write_text( + json.dumps( + { + "schema_version": "1.0", + "workspace": "https://ws.example.com", + "config": {"default_agent": "claude"}, + } + ), + encoding="utf-8", + ) + assert load_managed_state("https://ws.example.com") == {"default_agent": "claude"} + + def test_unversioned_legacy_state_is_read_as_v1(self, _managed_path): + _managed_path.parent.mkdir(parents=True, exist_ok=True) + _managed_path.write_text( + json.dumps( + { + "workspace": "https://ws.example.com", + "config": {"default_agent": "claude"}, + } + ), + encoding="utf-8", + ) + assert load_managed_state("https://ws.example.com") == {"default_agent": "claude"} + + def test_unknown_future_schema_is_not_interpreted_as_v1(self, _managed_path): + _managed_path.parent.mkdir(parents=True, exist_ok=True) + _managed_path.write_text( + json.dumps( + { + "schema_version": "2.0", + "workspace": "https://ws.example.com", + "config": {"default_agent": "claude"}, + } + ), + encoding="utf-8", + ) + assert load_managed_state("https://ws.example.com") is None + assert managed_state_workspace() is None + def test_saved_file_is_0600(self, _managed_path): save_managed_state("https://ws.example.com", {"default_agent": "claude"}) mode = stat.S_IMODE(os.stat(_managed_path).st_mode) # Owner-only read/write; no group/other bits. assert mode == 0o600 + def test_atomic_replace_failure_preserves_the_previous_draft(self, _managed_path, monkeypatch): + original = {"default_agent": "claude"} + save_managed_state("https://ws.example.com", original) + + def fail_replace(*_args): + raise OSError + + monkeypatch.setattr(mc_mod.os, "replace", fail_replace) + + with pytest.raises(RuntimeError, match="Failed to write managed config file"): + save_managed_state("https://ws.example.com", {"default_agent": "codex"}) + + assert load_managed_state("https://ws.example.com") == original + assert list(_managed_path.parent.glob(f".{_managed_path.name}.*.tmp")) == [] + def test_load_ignores_other_workspace(self, _managed_path): save_managed_state("https://ws-a.example.com", {"default_agent": "claude"}) assert load_managed_state("https://ws-b.example.com") is None @@ -233,6 +302,59 @@ def test_workspace_is_stored_alongside_the_config(self, _managed_path): save_managed_state("https://ws.example.com", {"default_agent": "claude"}) assert managed_state_workspace() == "https://ws.example.com" + def test_published_cache_is_separate_from_the_editable_draft(self, _managed_path): + draft = {"default_agent": "claude"} + published = {"default_agent": "codex"} + save_managed_state("https://ws.example.com", draft) + save_managed_cache("https://ws.example.com", published) + + assert load_managed_state("https://ws.example.com") == draft + assert load_managed_cache("https://ws.example.com") == published + + def test_workspace_caches_are_independent_and_include_provenance(self, _managed_path): + first_workspace = "https://first.example.com" + second_workspace = "https://second.example.com" + first = {"default_agent": "claude"} + second = {"default_agent": "codex"} + + save_managed_cache(first_workspace, first) + save_managed_cache(second_workspace, second) + + assert load_managed_cache(first_workspace) == first + assert load_managed_cache(second_workspace) == second + cache_files = sorted(mc_mod.MANAGED_CACHE_DIR.glob("*.json")) + assert len(cache_files) == 2 + payload = json.loads(mc_mod._managed_cache_path(first_workspace).read_text()) + assert payload["workspace"] == first_workspace + assert payload["metadata"]["source"] == "workspace" + assert payload["metadata"]["fetched_at"].endswith("Z") + assert payload["metadata"]["config_digest"] == mc_mod._config_digest(first) + + def test_reads_the_legacy_single_workspace_cache(self, _managed_path): + mc_mod.LEGACY_MANAGED_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + mc_mod.LEGACY_MANAGED_CACHE_PATH.write_text( + json.dumps( + { + "schema_version": "1.0", + "workspace": "https://ws.example.com", + "config": {"default_agent": "claude"}, + } + ), + encoding="utf-8", + ) + + assert load_managed_cache("https://ws.example.com") == {"default_agent": "claude"} + + def test_rejects_a_cache_whose_config_does_not_match_its_digest(self, _managed_path): + workspace = "https://ws.example.com" + save_managed_cache(workspace, {"default_agent": "claude"}) + cache_path = mc_mod._managed_cache_path(workspace) + payload = json.loads(cache_path.read_text(encoding="utf-8")) + payload["config"]["default_agent"] = "codex" + cache_path.write_text(json.dumps(payload), encoding="utf-8") + + assert load_managed_cache(workspace) is None + def test_workspace_is_none_when_absent(self, _managed_path): assert managed_state_workspace() is None @@ -329,13 +451,27 @@ def _stub_token(self, monkeypatch): def test_persists_and_returns_the_manifest(self, monkeypatch): saved: list[tuple] = [] monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (MANAGED, None)) - monkeypatch.setattr(mc_mod, "save_managed_state", lambda ws, cfg: saved.append((ws, cfg))) + monkeypatch.setattr(mc_mod, "save_managed_cache", lambda ws, cfg: saved.append((ws, cfg))) assert refresh_managed_config(_state()) == (MANAGED, False) assert saved == [(WORKSPACE, MANAGED)] + def test_refresh_never_overwrites_the_authored_draft(self, tmp_path, monkeypatch): + draft = {"default_agent": "codex", "enabled_agents": {"codex": {}}} + state_path = tmp_path / "managed-state.json" + cache_dir = tmp_path / "managed-cache" + monkeypatch.setattr(mc_mod, "MANAGED_STATE_PATH", state_path) + monkeypatch.setattr(mc_mod, "MANAGED_CACHE_DIR", cache_dir) + monkeypatch.setattr(mc_mod, "LEGACY_MANAGED_CACHE_PATH", tmp_path / "legacy-cache.json") + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (MANAGED, None)) + save_managed_state(WORKSPACE, draft) + + assert refresh_managed_config(_state()) == (MANAGED, False) + assert load_managed_state(WORKSPACE) == draft + assert load_managed_cache(WORKSPACE) == MANAGED + def test_no_managed_config_returns_none(self, monkeypatch): monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, None)) - monkeypatch.setattr(mc_mod, "save_managed_state", lambda ws, cfg: None) + monkeypatch.setattr(mc_mod, "save_managed_cache", lambda ws, cfg: None) result, _ = refresh_managed_config(_state()) assert result is None @@ -343,7 +479,7 @@ def test_read_failure_falls_back_to_the_persisted_config(self, monkeypatch): # The admin's last known policy beats no policy, so a failed fetch reuses what we saved. warnings: list[str] = [] monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, "HTTP 500")) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: MANAGED) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: MANAGED) monkeypatch.setattr(mc_mod, "print_warning", lambda msg: warnings.append(msg)) assert refresh_managed_config(_state()) == (MANAGED, False) assert "HTTP 500" in warnings[0] @@ -353,7 +489,7 @@ def test_read_failure_without_persisted_config_is_silent(self, monkeypatch): # Nothing persisted means no managed config is in play, so an expired session shouldn't # produce a warning about a feature this developer doesn't use. monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, "HTTP 500")) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: None) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: None) monkeypatch.setattr( mc_mod, "print_warning", lambda msg: pytest.fail(f"should not warn: {msg}") ) @@ -367,7 +503,7 @@ def boom(ws, profile): raise RuntimeError("no token") monkeypatch.setattr(mc_mod, "get_databricks_token", boom) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: MANAGED) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: MANAGED) monkeypatch.setattr(mc_mod, "print_warning", lambda msg: warnings.append(msg)) assert refresh_managed_config(_state()) == (MANAGED, False) assert "no token" in warnings[0] @@ -377,7 +513,7 @@ def boom(ws, profile): raise RuntimeError("no token") monkeypatch.setattr(mc_mod, "get_databricks_token", boom) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: None) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: None) monkeypatch.setattr( mc_mod, "print_warning", lambda msg: pytest.fail(f"should not warn: {msg}") ) @@ -389,7 +525,7 @@ def test_permission_denied_without_cache_is_silent(self, monkeypatch): # config in play and warning would be a false positive. denied = 'HTTP 403 Forbidden: {"error_code":"PERMISSION_DENIED"}' monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, denied)) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: None) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: None) monkeypatch.setattr( mc_mod, "print_warning", lambda msg: pytest.fail(f"should not warn: {msg}") ) @@ -402,10 +538,10 @@ def test_permission_denied_warns_and_keeps_the_cached_config(self, monkeypatch): warnings: list[str] = [] denied = 'HTTP 403 Forbidden: {"error_code":"PERMISSION_DENIED"}' monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, denied)) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: MANAGED) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: MANAGED) monkeypatch.setattr(mc_mod, "print_warning", lambda msg: warnings.append(msg)) monkeypatch.setattr( - mc_mod, "save_managed_state", lambda ws, cfg: pytest.fail("must not clear the cache") + mc_mod, "save_managed_cache", lambda ws, cfg: pytest.fail("must not clear the cache") ) assert refresh_managed_config(_state()) == (MANAGED, False) assert "not readable by you" in warnings[0] @@ -414,9 +550,9 @@ def test_no_config_on_the_server_does_not_use_a_stale_persisted_file(self, monke # A successful read saying "no config" means the admin removed it — that's authoritative, # so a previously persisted file must not resurrect the old policy. monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, None)) - monkeypatch.setattr(mc_mod, "save_managed_state", lambda ws, cfg: None) + monkeypatch.setattr(mc_mod, "save_managed_cache", lambda ws, cfg: None) monkeypatch.setattr( - mc_mod, "load_managed_state", lambda ws: pytest.fail("must not fall back") + mc_mod, "load_managed_cache", lambda ws: pytest.fail("must not fall back") ) result, _ = refresh_managed_config(_state()) assert result is None @@ -426,8 +562,8 @@ def test_no_config_on_the_server_clears_the_persisted_one(self, monkeypatch): # failed read would put a dead policy back into force. saved: list[tuple] = [] monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, None)) - monkeypatch.setattr(mc_mod, "save_managed_state", lambda ws, cfg: saved.append((ws, cfg))) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: None) + monkeypatch.setattr(mc_mod, "save_managed_cache", lambda ws, cfg: saved.append((ws, cfg))) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: None) result, _ = refresh_managed_config(_state()) assert result is None assert saved == [(WORKSPACE, {})] @@ -436,7 +572,7 @@ def test_empty_persisted_config_is_not_treated_as_a_fallback(self, monkeypatch): # The empty marker means "no admin policy", so a later failed read falls through to the # developer's own settings rather than reporting a managed config. monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, "HTTP 500")) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: {}) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: {}) monkeypatch.setattr( mc_mod, "print_warning", lambda msg: pytest.fail(f"should not warn: {msg}") ) @@ -455,7 +591,7 @@ def test_feature_disabled_sets_flag_when_there_is_no_fallback(self, monkeypatch) # publish anything yet. The flag lets callers suppress the setup recommendation. reason = 'HTTP 400 Bad Request: {"error_code":"FEATURE_DISABLED"}' monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, reason)) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: None) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: None) monkeypatch.setattr(mc_mod, "print_warning", lambda msg: None) state = _state() result, flag = refresh_managed_config(state) @@ -467,7 +603,7 @@ def test_feature_disabled_with_a_fallback_does_not_set_the_flag(self, monkeypatc # feature-off flag is irrelevant and must not be set. reason = 'HTTP 400 Bad Request: {"error_code":"FEATURE_DISABLED"}' monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, reason)) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: MANAGED) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: MANAGED) monkeypatch.setattr(mc_mod, "print_warning", lambda msg: None) state = _state() result, flag = refresh_managed_config(state) @@ -476,7 +612,7 @@ def test_feature_disabled_with_a_fallback_does_not_set_the_flag(self, monkeypatc def test_transient_failure_does_not_set_the_flag(self, monkeypatch): monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, "HTTP 500")) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: None) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: None) monkeypatch.setattr(mc_mod, "print_warning", lambda msg: None) state = _state() result, flag = refresh_managed_config(state) @@ -485,7 +621,7 @@ def test_transient_failure_does_not_set_the_flag(self, monkeypatch): def test_successful_no_config_clears_the_flag(self, monkeypatch): monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, None)) - monkeypatch.setattr(mc_mod, "save_managed_state", lambda ws, cfg: None) + monkeypatch.setattr(mc_mod, "save_managed_cache", lambda ws, cfg: None) state = _state() result, flag = refresh_managed_config(state) assert result is None diff --git a/tests/test_managed_wizard.py b/tests/test_managed_wizard.py index 8cdd9a28..f09d87be 100644 --- a/tests/test_managed_wizard.py +++ b/tests/test_managed_wizard.py @@ -272,7 +272,7 @@ def test_warning_does_not_itemize_the_existing_config(self): ): wizard._handle_existing_config(WORKSPACE, "token") message = warn.call_args[0][0] - assert "one config covers every agent" in message + assert message == "This workspace already has a managed config." for leaked in ("Claude Code", "OpenCode", "lillys_budget", "main.default"): assert leaked not in message, leaked @@ -334,6 +334,96 @@ def test_cancelling_the_picker_aborts(self): wizard._handle_existing_config(WORKSPACE, "token") +class TestSetupStepLayout: + def test_workspace_is_the_first_of_four_steps(self): + assert wizard.SETUP_STEP_TITLES == [ + "Select the workspace", + "Select coding agents", + "Select models for each agent", + "Select the default agent", + ] + + def test_workspace_picker_uses_the_first_step_banner(self): + steps: list[tuple[int, str]] = [] + successes: list[str] = [] + with ( + patch("ucode.cli._prompt_for_configuration", return_value=(WORKSPACE, "p")) as pick_ws, + patch( + "ucode.cli.configure_shared_state", return_value={**STATE, "profile": "p"} + ) as configure, + patch.object( + wizard, "_step_banner", side_effect=lambda i, title: steps.append((i, title)) + ), + patch.object(wizard, "ensure_databricks_auth"), + patch.object(wizard, "get_databricks_token", return_value="token"), + patch.object(wizard, "_require_admin", return_value=True), + patch.object(wizard, "ensure_ai_gateway"), + patch.object(wizard, "_handle_existing_config", return_value=(True, None)), + patch.object(wizard, "check_gateway_endpoint", return_value=True), + patch.object(wizard, "load_managed_state", return_value=None), + patch.object(wizard, "prompt_for_tools", return_value=[]) as pick_agents, + patch.object(wizard, "print_success", side_effect=successes.append), + ): + assert wizard.setup_command() == 0 + + pick_ws.assert_called_once_with(show_section=False, prompt="Workspace:") + assert steps == [(1, "Select the workspace"), (2, "Select coding agents")] + assert pick_agents.call_args.kwargs["prompt"] == "Coding agents:" + assert successes == ["Workspace checks complete"] + assert configure.call_args.kwargs["connection_checks_verified"] is True + + def test_agent_setup_does_not_offer_global_routing(self): + completion_order: list[str] = [] + with ( + patch("ucode.cli._prompt_for_configuration", return_value=(WORKSPACE, "p")), + patch("ucode.cli.configure_shared_state", return_value={**STATE, "profile": "p"}), + patch.object(wizard, "ensure_databricks_auth"), + patch.object(wizard, "get_databricks_token", return_value="token"), + patch.object(wizard, "_require_admin", return_value=True), + patch.object(wizard, "ensure_ai_gateway"), + patch.object(wizard, "_handle_existing_config", return_value=(True, None)), + patch.object(wizard, "check_gateway_endpoint", return_value=True), + patch.object(wizard, "load_managed_state", return_value=None), + patch.object(wizard, "prompt_for_tools", return_value=["codex"]), + patch.object(wizard, "_select_provider_service", return_value=None), + patch.object( + wizard, + "_prompt_models_for_agent", + return_value={"default_model": "system.ai.gpt-5-6"}, + ), + patch.object(wizard, "validate_manifest", return_value=[]), + patch.object(wizard, "save_managed_state") as save, + patch.object( + wizard, + "_render_summary", + side_effect=lambda *_: completion_order.append("summary"), + ), + patch.object( + wizard, + "print_wizard_outro", + side_effect=lambda *_args, **_kwargs: completion_order.append("outro"), + ), + patch.object( + wizard, + "print_success", + side_effect=lambda message: ( + completion_order.append("saved") if message == "Configuration saved" else None + ), + ), + patch.object(wizard, "_print_next_steps"), + patch.object(wizard, "_offer_apply"), + patch.object(wizard, "prompt_yes_no_default") as yes_no, + ): + assert wizard.setup_command() == 0 + + yes_no.assert_not_called() + manifest = save.call_args.args[1] + assert manifest["enabled_agents"]["codex"] == { + "model_config": {"default_model": "system.ai.gpt-5-6"} + } + assert completion_order == ["outro", "saved", "summary"] + + class TestModelPrompting: def test_codex_takes_a_single_model(self): with patch.object(wizard, "prompt_for_selection", return_value="system.ai.gpt-5-6"): @@ -423,6 +513,7 @@ def fake_sel(prompt, options, **kwargs): patch.object(wizard, "_claude_candidates", return_value=candidates), patch.object(wizard, "prompt_for_selection", side_effect=fake_sel), patch.object(wizard, "print_note"), + patch.object(wizard, "print_panel") as panel, ): config = wizard._prompt_models_for_agent("claude", STATE, None) @@ -430,6 +521,7 @@ def fake_sel(prompt, options, **kwargs): # never name a model the config doesn't carry. assert set(prompts[-1]) == {"system.ai.claude-opus-5", "system.ai.claude-sonnet-5"} assert config["default_model"] in config["models"].values() + panel.assert_not_called() def test_claude_single_slot_skips_the_default_prompt(self): candidates = {"opus": ["system.ai.claude-opus-5"]} @@ -1682,6 +1774,8 @@ def test_lists_claude_family_slots(self, capsys): } wizard._render_summary(WORKSPACE, manifest) out = capsys.readouterr().out + assert "Complete configuration" in out + assert "╭" in out and "╯" in out assert "opus" in out and "haiku" in out assert "system.ai.claude-haiku-4-5" in out @@ -1712,8 +1806,9 @@ def test_single_model_agent_needs_no_extra_line(self, capsys): assert "system.ai.gemini-3-flash" in out assert "models:" not in out - def test_scope_label_only_for_global_capable_agents(self, capsys): - # claude/codex can use global settings, so they carry the scope; gemini can't, so it doesn't. + def test_scope_label_only_for_explicit_legacy_setting(self, capsys): + # Imported/older manifests keep their explicit scope visible; newly-authored entries that + # omit the retired setup choice do not get an implied "ucode-only" label. manifest = { "default_agent": "claude", "enabled_agents": { @@ -1722,6 +1817,7 @@ def test_scope_label_only_for_global_capable_agents(self, capsys): "use_as_global_settings": True, }, "gemini": {"model_config": {"default_model": "system.ai.gemini-3-flash"}}, + "codex": {"model_config": {"default_model": "system.ai.gpt-5-6"}}, }, } wizard._render_summary(WORKSPACE, manifest) @@ -1730,6 +1826,8 @@ def test_scope_label_only_for_global_capable_agents(self, capsys): # The gemini line names its model but carries no global-settings/ucode-only scope. gemini_line = next(line for line in out.splitlines() if "gemini-3-flash" in line) assert "ucode-only" not in gemini_line and "global settings" not in gemini_line + codex_line = next(line for line in out.splitlines() if "gpt-5-6" in line) + assert "ucode-only" not in codex_line and "global settings" not in codex_line class TestSetupFromFile: @@ -1818,7 +1916,7 @@ def test_summary_is_boxed(self, capsys): }, ) out = capsys.readouterr().out - assert "Configuration summary" in out + assert "Complete configuration" in out # Rich box-drawing characters: the panel border. assert "╭" in out and "╰" in out assert "system.ai.claude-opus-5" in out @@ -2255,6 +2353,8 @@ def test_lists_every_setup_command(self, capsys): "ucode setup skills", "ucode setup spend-tiers", "ucode setup show", + "ucode --local", + "ucode --local", "ucode apply", ): assert command in out @@ -2579,15 +2679,24 @@ def test_apply_is_registered(self): assert result.exit_code == 0 assert "apply" in result.output - def test_apply_declares_yes_and_no_dry_run(self): + def test_apply_declares_yes_and_no_mode_flags(self): # `--dry-run` was removed: apply always validates before publishing, so a separate # validate-only mode is redundant. Asserted on declared options rather than rendered help, # which Rich ellipsizes at narrow widths (see test_setup_help_lists_from_file). command = typer.main.get_command(app).commands["apply"] # type: ignore[attr-defined] declared = {opt for param in command.params for opt in param.opts} assert "--yes" in declared + assert "--local" not in declared + assert "--workspace" not in declared assert "--dry-run" not in declared + def test_apply_publishes_the_latest_draft(self): + with patch.object(cli_mod, "apply_command", return_value=0) as apply: + result = runner.invoke(app, ["apply", "--yes"]) + + assert result.exit_code == 0, result.output + assert apply.call_args.kwargs == {"yes": True} + def test_apply_error_exits_nonzero_with_a_message(self): with patch.object(cli_mod, "apply_command", side_effect=RuntimeError("no config authored")): result = runner.invoke(app, ["apply"]) diff --git a/tests/test_managed_workflow.py b/tests/test_managed_workflow.py new file mode 100644 index 00000000..bce20923 --- /dev/null +++ b/tests/test_managed_workflow.py @@ -0,0 +1,133 @@ +"""End-to-end managed-config workflow with only external systems stubbed. + +Exercises the real CLI orchestration, draft/cache persistence, manifest serialization, +managed-state resolution, and Claude config writer across setup, local testing, publishing, and a +normal workspace-managed launch. +""" + +from __future__ import annotations + +import contextlib +import json +from unittest.mock import patch + +from typer.testing import CliRunner + +import ucode.managed_config as managed_config +import ucode.managed_wizard as managed_wizard +from ucode.agents import claude +from ucode.cli import app + +runner = CliRunner() + +WORKSPACE = "https://workspace.example.com" +PUBLISHED_MODEL = "system.ai.claude-opus-4-8" +UNPUBLISHED_MODEL = "system.ai.claude-opus-4-9" + + +def _manifest(model: str) -> dict: + return { + "default_agent": "claude", + "enabled_agents": { + "claude": { + "model_config": { + "default_model": model, + "models": {"default_opus_model": model}, + } + } + }, + } + + +def _configured_opus(settings: dict) -> str: + return settings["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"].removesuffix("[1m]") + + +def test_setup_local_apply_and_normal_launch_keep_sources_separate(tmp_path, monkeypatch): + state = { + "workspace": WORKSPACE, + "profile": "DEFAULT", + "available_tools": ["claude"], + "base_urls": {"claude": f"{WORKSPACE}/ai-gateway/anthropic"}, + "claude_models": {"opus": PUBLISHED_MODEL}, + "all_claude_models": [PUBLISHED_MODEL, UNPUBLISHED_MODEL], + "managed_configs": {}, + } + manifest_path = tmp_path / "managed-config.json" + manifest_path.write_text(json.dumps(_manifest(PUBLISHED_MODEL)), encoding="utf-8") + settings_path = tmp_path / "claude" / "ucode-settings.json" + + monkeypatch.setattr(managed_config, "MANAGED_STATE_PATH", tmp_path / "managed-state.json") + monkeypatch.setattr(managed_config, "MANAGED_CACHE_DIR", tmp_path / "managed-cache") + monkeypatch.setattr( + managed_config, "LEGACY_MANAGED_CACHE_PATH", tmp_path / "legacy-managed-cache.json" + ) + monkeypatch.setattr(claude, "CLAUDE_SETTINGS_PATH", settings_path) + monkeypatch.setattr(claude, "CLAUDE_BACKUP_PATH", tmp_path / "claude-settings.backup.json") + + published_payload: dict = {} + launches: list[str] = [] + + def publish(_workspace, _token, payload): + published_payload.update(payload) + return {"name": "coding-agent-configs/test"}, None + + patches = [ + patch("ucode.cli.install_databricks_cli"), + patch("ucode.cli.load_state", return_value=state), + patch.object(managed_wizard, "load_state", return_value=state), + patch("ucode.cli.apply_pat_environment"), + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch("ucode.cli.ensure_provider_state", return_value=state), + patch("ucode.cli._require_local_config_admin"), + patch("ucode.cli.configure_shared_state", return_value=state), + patch("ucode.cli._fetch_budget_recommendation", return_value=None), + patch("ucode.cli._register_managed_mcp_servers"), + patch("ucode.cli._apply_managed_skills"), + patch("ucode.cli.launch_agent", side_effect=lambda tool, *_args: launches.append(tool)), + patch.object(claude, "managed_settings_model_overrides", return_value=None), + patch.object(claude, "agent_version", return_value="test"), + patch.object(claude, "ucode_version", return_value="test"), + patch.object(managed_wizard, "ensure_databricks_auth"), + patch.object(managed_wizard, "get_databricks_token", return_value="token"), + patch.object(managed_wizard, "is_workspace_admin", return_value=True), + patch.object(managed_wizard, "get_managed_config", return_value=(None, None)), + patch.object(managed_wizard, "create_coding_agent_config", side_effect=publish), + ] + with contextlib.ExitStack() as stack: + for managed_patch in patches: + stack.enter_context(managed_patch) + + setup_result = runner.invoke(app, ["setup", "--from-file", str(manifest_path)]) + assert setup_result.exit_code == 0, setup_result.output + assert managed_config.load_managed_state(WORKSPACE) == _manifest(PUBLISHED_MODEL) + + local_result = runner.invoke(app, ["claude", "--local"]) + assert local_result.exit_code == 0, local_result.output + local_settings = json.loads(settings_path.read_text(encoding="utf-8")) + assert _configured_opus(local_settings) == PUBLISHED_MODEL + + apply_result = runner.invoke(app, ["apply", "--yes"]) + assert apply_result.exit_code == 0, apply_result.output + published = managed_config.normalize_managed_config(published_payload) + assert published["enabled_agents"]["claude"]["model_config"]["default_model"] == ( + PUBLISHED_MODEL + ) + + # The admin continues editing after publication. A normal developer launch must still use + # the workspace-published snapshot, not this newer local draft. + managed_config.save_managed_state(WORKSPACE, _manifest(UNPUBLISHED_MODEL)) + with ( + patch.dict("os.environ", {managed_config.MANAGED_CONFIG_ENV_VAR: "1"}), + patch.object(managed_config, "get_databricks_token", return_value="token"), + patch.object(managed_config, "get_managed_config", return_value=(published, None)), + ): + normal_result = runner.invoke(app, ["claude"]) + + assert normal_result.exit_code == 0, normal_result.output + + normal_settings = json.loads(settings_path.read_text(encoding="utf-8")) + assert _configured_opus(normal_settings) == PUBLISHED_MODEL + assert managed_config.load_managed_state(WORKSPACE) == _manifest(UNPUBLISHED_MODEL) + assert managed_config.load_managed_cache(WORKSPACE) == published + assert launches == ["claude", "claude"] diff --git a/tests/test_ui.py b/tests/test_ui.py index c1ef9980..626dcd8c 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -17,11 +17,17 @@ format_meter, format_token_count, format_usd, + inquirerpy_wizard, normalize_workspace_url, + print_success, + print_wizard_header, + print_wizard_outro, + print_wizard_step, prompt_for_multi_selection, prompt_for_percentage, prompt_for_selection, prompt_for_text, + prompt_for_tools, prompt_for_workspace, prompt_yes_no_default, render_box_table, @@ -29,6 +35,177 @@ ) +class TestWizardLayout: + def test_header_is_branded_without_a_box(self, capsys): + print_wizard_header("ucode setup", "Managed defaults") + print_wizard_outro("Ready") + out = capsys.readouterr().out + assert "ucode setup" in out + assert "Managed defaults" in out + assert "┌" in out and "└" in out + assert "╭" not in out and "╰" not in out + + def test_steps_form_a_compact_vertical_rail(self, capsys): + print_wizard_step(1, 4, "Select the workspace") + print_wizard_step(2, 4, "Select coding agents") + out = capsys.readouterr().out + assert "◇ 1/4 Select the workspace" in out + assert "◇ 2/4 Select coding agents" in out + assert "│" in out + assert "╭" not in out and "╰" not in out + + def test_status_output_stays_inside_the_rail(self, capsys): + @inquirerpy_wizard + def render(): + print_wizard_header("ucode setup", "Managed defaults") + print_wizard_step(1, 4, "Select the workspace") + print_success("Admin permissions verified") + print_wizard_outro("Ready") + + render() + status_line = next(line for line in capsys.readouterr().out.splitlines() if "Admin" in line) + assert status_line.startswith("│ ✔") + + def test_inquirer_choice_rows_stay_inside_the_rail(self): + class FakeControl: + def _get_hover_text(self, choice): + return [("class:pointer", "❯"), ("", f" {choice}")] + + def _get_normal_text(self, choice): + return [("", " "), ("", str(choice))] + + class FakeQuestion: + content_control = FakeControl() + + @inquirerpy_wizard + def render(): + print_wizard_header("ucode setup", "Managed defaults") + question = ui_mod._with_inquirer_rail(FakeQuestion()) + return ( + question.content_control._get_hover_text("first"), + question.content_control._get_normal_text("second"), + ) + + hover, normal = render() + assert "".join(text for _, text in hover) == "│ ❯ first" + assert "".join(text for _, text in normal) == "│ second" + + +class TestInquirerPyPrompts: + def test_agent_picker_uses_clack_style_controls_and_summary(self, monkeypatch): + captured: dict = {} + + def fake_checkbox(**kwargs): + captured.update(kwargs) + return _StubInquirerPrompt(["codex", "claude"]) + + monkeypatch.setattr(ui_mod.inquirer, "checkbox", fake_checkbox) + + @inquirerpy_wizard + def run(): + print_wizard_header("ucode setup", "Managed defaults") + result = prompt_for_tools( + [("codex", "Codex"), ("claude", "Claude Code"), ("pi", "Pi")], + preselected=["codex", "claude"], + prompt="Coding agents:", + ) + print_wizard_outro("Ready") + return result + + assert run() == ["codex", "claude"] + assert captured["qmark"] == "│ " + assert captured["enabled_symbol"] == "◉" + assert captured["disabled_symbol"] == "○" + assert captured["transformer"](["codex", "claude"]) == "Codex, Claude Code" + + def test_short_searchable_picker_has_one_cursor_and_no_blank_filter_row(self, monkeypatch): + captured: dict = {} + + def fake_select(**kwargs): + captured.update(kwargs) + return _StubInquirerPrompt("custom") + + monkeypatch.setattr(ui_mod.inquirer, "select", fake_select) + monkeypatch.setattr( + ui_mod.inquirer, + "fuzzy", + lambda **_: pytest.fail("a short picker should not render a fuzzy-search input"), + ) + + @inquirerpy_wizard + def run(): + return prompt_for_selection( + "Default fable model:", + [("fable", "system.ai.claude-fable-5"), ("custom", "Enter a custom model…")], + searchable=True, + ) + + assert run() == "custom" + assert captured["pointer"] == "❯" + + def test_long_fuzzy_picker_labels_its_filter_row(self, monkeypatch): + captured: dict = {} + + def fake_fuzzy(**kwargs): + captured.update(kwargs) + return _StubInquirerPrompt("m0") + + monkeypatch.setattr(ui_mod.inquirer, "fuzzy", fake_fuzzy) + + @inquirerpy_wizard + def run(): + print_wizard_header("ucode setup", "Managed defaults") + result = prompt_for_selection( + "Model:", [(f"m{i}", f"model {i}") for i in range(11)], searchable=True + ) + print_wizard_outro("Ready") + return result + + assert run() == "m0" + assert captured["prompt"] == "│ Filter" + + def test_long_fuzzy_multi_picker_toggles_with_space(self, monkeypatch): + captured: dict = {} + + def fake_fuzzy(**kwargs): + captured.update(kwargs) + return _StubInquirerPrompt(["m0"]) + + monkeypatch.setattr(ui_mod.inquirer, "fuzzy", fake_fuzzy) + + @inquirerpy_wizard + def run(): + print_wizard_header("ucode setup", "Managed defaults") + result = prompt_for_multi_selection( + "Models:", [(f"m{i}", f"model {i}") for i in range(11)], searchable=True + ) + print_wizard_outro("Ready") + return result + + assert run() == ["m0"] + assert captured["prompt"] == "│ Filter" + assert captured["keybindings"] == {"toggle": [{"key": "space"}]} + + def test_confirmation_names_the_default_instead_of_claiming_it_is_highlighted( + self, monkeypatch + ): + captured: dict = {} + + def fake_confirm(**kwargs): + captured.update(kwargs) + return _StubInquirerPrompt(False) + + monkeypatch.setattr(ui_mod.inquirer, "confirm", fake_confirm) + + @inquirerpy_wizard + def run(): + return prompt_yes_no_default("Publish?", default=False) + + assert run() is False + assert captured["instruction"] == "y/N · enter selects No" + assert "highlight" not in captured["instruction"] + + class TestPromptYesNoDefault: def _answer(self, monkeypatch, value): # value: a string the user "types", or EOFError to simulate closed stdin. @@ -354,6 +531,14 @@ def ask(self): return self._answer +class _StubInquirerPrompt: + def __init__(self, answer): + self._answer = answer + + def execute(self): + return self._answer + + class TestPromptForWorkspace: """Capture the choices passed to ``questionary.select`` so we can assert on layout (header alignment + duplicate-host preservation) without driving @@ -397,6 +582,47 @@ def test_shows_header_and_each_profile_row(self, monkeypatch): # Final fallback entry still present. assert choices[3].title == "Enter a different URL" + def test_uses_the_standard_section_panel(self, monkeypatch): + profiles = [("https://a.cloud.databricks.com", "alpha")] + self._capture_select(monkeypatch, answer=profiles[0]) + with patch.object(ui_mod, "print_section") as section: + prompt_for_workspace("Databricks workspace", profiles) + section.assert_called_once_with("Databricks workspace") + + def test_larger_wizard_can_supply_its_own_step_banner(self, monkeypatch): + profiles = [("https://a.cloud.databricks.com", "alpha")] + captured = self._capture_select(monkeypatch, answer=profiles[0]) + with patch.object(ui_mod, "print_section") as section: + prompt_for_workspace("unused", profiles, show_section=False, prompt="Workspace:") + section.assert_not_called() + assert captured["message"] == "Workspace:" + + def test_inquirerpy_workspace_prompt_inherits_the_wizard_rail(self, monkeypatch): + profiles = [("https://a.cloud.databricks.com", "alpha")] + captured: dict = {} + + def fake_select(**kwargs): + captured.update(kwargs) + return _StubInquirerPrompt(profiles[0]) + + monkeypatch.setattr(ui_mod.inquirer, "select", fake_select) + + @inquirerpy_wizard + def run(): + print_wizard_header("ucode setup", "Managed defaults") + result = prompt_for_workspace( + "unused", profiles, show_section=False, prompt="Workspace:" + ) + print_wizard_outro("Ready") + return result + + assert run() == profiles[0] + assert captured["qmark"] == "│ " + assert captured["amark"] == "│ " + assert captured["pointer"] == "❯" + selected_row = "alpha https://a.cloud.databricks.com" + assert captured["transformer"](selected_row) == selected_row + def test_keeps_duplicate_hosts_as_separate_rows(self, monkeypatch): profiles = [ ("https://shared.cloud.databricks.com", "first"), diff --git a/uv.lock b/uv.lock index 0b7d205b..1071ffb6 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ [[package]] name = "aiohappyeyeballs" version = "2.6.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, @@ -19,7 +19,7 @@ wheels = [ [[package]] name = "aiohttp" version = "3.13.5" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "aiohappyeyeballs" }, { name = "aiosignal" }, @@ -104,7 +104,7 @@ wheels = [ [[package]] name = "aiosignal" version = "1.4.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "frozenlist" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, @@ -117,7 +117,7 @@ wheels = [ [[package]] name = "alembic" version = "1.18.4" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, @@ -131,7 +131,7 @@ wheels = [ [[package]] name = "annotated-doc" version = "0.0.4" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, @@ -140,7 +140,7 @@ wheels = [ [[package]] name = "annotated-types" version = "0.7.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, @@ -149,7 +149,7 @@ wheels = [ [[package]] name = "anyio" version = "4.13.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, @@ -162,7 +162,7 @@ wheels = [ [[package]] name = "attrs" version = "26.1.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, @@ -171,7 +171,7 @@ wheels = [ [[package]] name = "azure-core" version = "1.41.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, @@ -184,7 +184,7 @@ wheels = [ [[package]] name = "azure-storage-blob" version = "12.29.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "azure-core" }, { name = "cryptography" }, @@ -199,7 +199,7 @@ wheels = [ [[package]] name = "azure-storage-file-datalake" version = "12.24.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "azure-core" }, { name = "azure-storage-blob" }, @@ -214,7 +214,7 @@ wheels = [ [[package]] name = "blinker" version = "1.9.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, @@ -223,7 +223,7 @@ wheels = [ [[package]] name = "boto3" version = "1.43.14" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, @@ -237,7 +237,7 @@ wheels = [ [[package]] name = "botocore" version = "1.43.14" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, @@ -251,7 +251,7 @@ wheels = [ [[package]] name = "cachetools" version = "7.1.4" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, @@ -260,7 +260,7 @@ wheels = [ [[package]] name = "certifi" version = "2026.4.22" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, @@ -269,7 +269,7 @@ wheels = [ [[package]] name = "cffi" version = "2.0.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] @@ -326,7 +326,7 @@ wheels = [ [[package]] name = "charset-normalizer" version = "3.4.7" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, @@ -399,7 +399,7 @@ wheels = [ [[package]] name = "click" version = "8.3.3" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] @@ -411,7 +411,7 @@ wheels = [ [[package]] name = "cloudpickle" version = "3.1.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, @@ -420,7 +420,7 @@ wheels = [ [[package]] name = "colorama" version = "0.4.6" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, @@ -429,7 +429,7 @@ wheels = [ [[package]] name = "contourpy" version = "1.3.3" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "numpy" }, ] @@ -495,7 +495,7 @@ wheels = [ [[package]] name = "cryptography" version = "46.0.7" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] @@ -548,7 +548,7 @@ wheels = [ [[package]] name = "cycler" version = "0.12.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, @@ -557,7 +557,7 @@ wheels = [ [[package]] name = "databricks-agents" version = "1.11.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "boto3" }, { name = "botocore" }, @@ -582,7 +582,7 @@ wheels = [ [[package]] name = "databricks-sdk" version = "0.114.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "google-auth" }, { name = "protobuf" }, @@ -596,7 +596,7 @@ wheels = [ [[package]] name = "databricks-sql-connector" version = "4.2.5" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "lz4" }, { name = "oauthlib" }, @@ -617,7 +617,7 @@ wheels = [ [[package]] name = "dataclasses-json" version = "0.6.7" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "marshmallow" }, { name = "typing-inspect" }, @@ -630,7 +630,7 @@ wheels = [ [[package]] name = "docker" version = "7.1.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "requests" }, @@ -644,7 +644,7 @@ wheels = [ [[package]] name = "et-xmlfile" version = "2.0.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, @@ -653,7 +653,7 @@ wheels = [ [[package]] name = "fastapi" version = "0.136.3" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, @@ -669,7 +669,7 @@ wheels = [ [[package]] name = "flask" version = "3.1.3" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "blinker" }, { name = "click" }, @@ -686,7 +686,7 @@ wheels = [ [[package]] name = "flask-cors" version = "6.0.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "flask" }, { name = "werkzeug" }, @@ -699,7 +699,7 @@ wheels = [ [[package]] name = "fonttools" version = "4.63.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, @@ -740,7 +740,7 @@ wheels = [ [[package]] name = "frozenlist" version = "1.8.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, @@ -829,7 +829,7 @@ wheels = [ [[package]] name = "gitdb" version = "4.0.12" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "smmap" }, ] @@ -841,7 +841,7 @@ wheels = [ [[package]] name = "gitpython" version = "3.1.50" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "gitdb" }, ] @@ -853,7 +853,7 @@ wheels = [ [[package]] name = "google-api-core" version = "2.30.3" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "google-auth" }, { name = "googleapis-common-protos" }, @@ -869,7 +869,7 @@ wheels = [ [[package]] name = "google-auth" version = "2.53.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, @@ -882,7 +882,7 @@ wheels = [ [[package]] name = "google-cloud-core" version = "2.6.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "google-api-core" }, { name = "google-auth" }, @@ -895,7 +895,7 @@ wheels = [ [[package]] name = "google-cloud-storage" version = "3.10.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "google-api-core" }, { name = "google-auth" }, @@ -912,7 +912,7 @@ wheels = [ [[package]] name = "google-crc32c" version = "1.8.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, @@ -935,7 +935,7 @@ wheels = [ [[package]] name = "google-resumable-media" version = "2.9.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "google-crc32c" }, ] @@ -947,7 +947,7 @@ wheels = [ [[package]] name = "googleapis-common-protos" version = "1.75.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "protobuf" }, ] @@ -959,7 +959,7 @@ wheels = [ [[package]] name = "graphene" version = "3.4.3" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "graphql-core" }, { name = "graphql-relay" }, @@ -974,7 +974,7 @@ wheels = [ [[package]] name = "graphql-core" version = "3.2.8" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/68/c5/36aa96205c3ecbb3d34c7c24189e4553c7ca2ebc7e1dd07432339b980272/graphql_core-3.2.8.tar.gz", hash = "sha256:015457da5d996c924ddf57a43f4e959b0b94fb695b85ed4c29446e508ed65cf3", size = 513181, upload-time = "2026-03-05T19:55:37.332Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/86/41/cb887d9afc5dabd78feefe6ccbaf83ff423c206a7a1b7aeeac05120b2125/graphql_core-3.2.8-py3-none-any.whl", hash = "sha256:cbee07bee1b3ed5e531723685369039f32ff815ef60166686e0162f540f1520c", size = 207349, upload-time = "2026-03-05T19:55:35.911Z" }, @@ -983,7 +983,7 @@ wheels = [ [[package]] name = "graphql-relay" version = "3.2.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "graphql-core" }, ] @@ -995,13 +995,15 @@ wheels = [ [[package]] name = "greenlet" version = "3.5.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, @@ -1009,7 +1011,9 @@ wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, @@ -1017,7 +1021,9 @@ wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, @@ -1025,14 +1031,18 @@ wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, @@ -1040,7 +1050,9 @@ wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://pypi-proxy.cloud.databricks.com/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, @@ -1050,7 +1062,7 @@ wheels = [ [[package]] name = "gunicorn" version = "25.3.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "packaging" }, ] @@ -1062,7 +1074,7 @@ wheels = [ [[package]] name = "h11" version = "0.16.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, @@ -1071,7 +1083,7 @@ wheels = [ [[package]] name = "httpcore" version = "1.0.9" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "certifi" }, { name = "h11" }, @@ -1084,7 +1096,7 @@ wheels = [ [[package]] name = "httpx" version = "0.28.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "anyio" }, { name = "certifi" }, @@ -1099,7 +1111,7 @@ wheels = [ [[package]] name = "httpx-sse" version = "0.4.3" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, @@ -1108,7 +1120,7 @@ wheels = [ [[package]] name = "huey" version = "2.6.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/fe/29/3428d52eb8e85025e264a291641a9f9d6407cc1e51d1b630f6ac5815999a/huey-2.6.0.tar.gz", hash = "sha256:8d11f8688999d65266af1425b831f6e3773e99415027177b8734b0ffd5e251f6", size = 221068, upload-time = "2026-01-06T03:01:02.055Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/1a/34/fae9ac8f1c3a552fd3f7ff652b94c78d219dedc5fce0c0a4232457760a00/huey-2.6.0-py3-none-any.whl", hash = "sha256:1b9df9d370b49c6d5721ba8a01ac9a787cf86b3bdc584e4679de27b920395c3f", size = 76951, upload-time = "2026-01-06T03:01:00.808Z" }, @@ -1117,7 +1129,7 @@ wheels = [ [[package]] name = "idna" version = "3.13" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, @@ -1126,7 +1138,7 @@ wheels = [ [[package]] name = "importlib-metadata" version = "9.0.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "zipp" }, ] @@ -1138,16 +1150,29 @@ wheels = [ [[package]] name = "iniconfig" version = "2.3.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "inquirerpy" +version = "0.3.4" +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } +dependencies = [ + { name = "pfzy" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/64/73/7570847b9da026e07053da3bbe2ac7ea6cde6bb2cbd3c7a5a950fa0ae40b/InquirerPy-0.3.4.tar.gz", hash = "sha256:89d2ada0111f337483cb41ae31073108b2ec1e618a49d7110b0d7ade89fc197e", size = 44431, upload-time = "2022-06-27T23:11:20.598Z" } +wheels = [ + { url = "https://pypi-proxy.cloud.databricks.com/packages/ce/ff/3b59672c47c6284e8005b42e84ceba13864aa0f39f067c973d1af02f5d91/InquirerPy-0.3.4-py3-none-any.whl", hash = "sha256:c65fdfbac1fa00e3ee4fb10679f4d3ed7a012abf4833910e63c295827fe2a7d4", size = 67677, upload-time = "2022-06-27T23:11:17.723Z" }, +] + [[package]] name = "isodate" version = "0.7.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, @@ -1156,7 +1181,7 @@ wheels = [ [[package]] name = "itsdangerous" version = "2.2.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, @@ -1165,7 +1190,7 @@ wheels = [ [[package]] name = "jinja2" version = "3.1.6" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "markupsafe" }, ] @@ -1177,7 +1202,7 @@ wheels = [ [[package]] name = "jmespath" version = "1.1.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, @@ -1186,7 +1211,7 @@ wheels = [ [[package]] name = "joblib" version = "1.5.3" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, @@ -1195,7 +1220,7 @@ wheels = [ [[package]] name = "jsonschema" version = "4.26.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "attrs" }, { name = "jsonschema-specifications" }, @@ -1210,7 +1235,7 @@ wheels = [ [[package]] name = "jsonschema-specifications" version = "2025.9.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "referencing" }, ] @@ -1222,7 +1247,7 @@ wheels = [ [[package]] name = "kiwisolver" version = "1.5.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, @@ -1308,7 +1333,7 @@ wheels = [ [[package]] name = "lz4" version = "4.4.5" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/1b/ac/016e4f6de37d806f7cc8f13add0a46c9a7cfc41a5ddc2bc831d7954cf1ce/lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e", size = 207163, upload-time = "2025-11-03T13:01:45.895Z" }, @@ -1348,7 +1373,7 @@ wheels = [ [[package]] name = "mako" version = "1.3.12" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "markupsafe" }, ] @@ -1360,7 +1385,7 @@ wheels = [ [[package]] name = "markdown-it-py" version = "4.0.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "mdurl" }, ] @@ -1372,7 +1397,7 @@ wheels = [ [[package]] name = "markupsafe" version = "3.0.3" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, @@ -1435,7 +1460,7 @@ wheels = [ [[package]] name = "marshmallow" version = "3.26.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "packaging" }, ] @@ -1447,7 +1472,7 @@ wheels = [ [[package]] name = "matplotlib" version = "3.10.9" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "contourpy" }, { name = "cycler" }, @@ -1501,7 +1526,7 @@ wheels = [ [[package]] name = "mcp" version = "1.28.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "anyio" }, { name = "httpx" }, @@ -1526,7 +1551,7 @@ wheels = [ [[package]] name = "mdurl" version = "0.1.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, @@ -1535,7 +1560,7 @@ wheels = [ [[package]] name = "mlflow" version = "3.12.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "aiohttp" }, { name = "alembic" }, @@ -1575,7 +1600,7 @@ databricks = [ [[package]] name = "mlflow-skinny" version = "3.12.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "cachetools" }, { name = "click" }, @@ -1606,7 +1631,7 @@ wheels = [ [[package]] name = "mlflow-tracing" version = "3.12.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "cachetools" }, { name = "databricks-sdk" }, @@ -1625,7 +1650,7 @@ wheels = [ [[package]] name = "multidict" version = "6.7.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, @@ -1724,7 +1749,7 @@ wheels = [ [[package]] name = "mypy-extensions" version = "1.1.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, @@ -1733,7 +1758,7 @@ wheels = [ [[package]] name = "numpy" version = "2.4.4" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, @@ -1794,7 +1819,7 @@ wheels = [ [[package]] name = "oauthlib" version = "3.3.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/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" }, @@ -1803,7 +1828,7 @@ wheels = [ [[package]] name = "openpyxl" version = "3.1.5" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "et-xmlfile" }, ] @@ -1815,7 +1840,7 @@ wheels = [ [[package]] name = "opentelemetry-api" version = "1.42.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "typing-extensions" }, ] @@ -1827,7 +1852,7 @@ wheels = [ [[package]] name = "opentelemetry-proto" version = "1.42.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "protobuf" }, ] @@ -1839,7 +1864,7 @@ wheels = [ [[package]] name = "opentelemetry-sdk" version = "1.42.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, @@ -1853,7 +1878,7 @@ wheels = [ [[package]] name = "opentelemetry-semantic-conventions" version = "0.63b1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, @@ -1866,7 +1891,7 @@ wheels = [ [[package]] name = "packaging" version = "26.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, @@ -1875,7 +1900,7 @@ wheels = [ [[package]] name = "pandas" version = "2.3.3" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "numpy" }, { name = "python-dateutil" }, @@ -1919,10 +1944,19 @@ wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, ] +[[package]] +name = "pfzy" +version = "0.3.4" +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } +sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/d9/5a/32b50c077c86bfccc7bed4881c5a2b823518f5450a30e639db5d3711952e/pfzy-0.3.4.tar.gz", hash = "sha256:717ea765dd10b63618e7298b2d98efd819e0b30cd5905c9707223dceeb94b3f1", size = 8396, upload-time = "2022-01-28T02:26:17.946Z" } +wheels = [ + { url = "https://pypi-proxy.cloud.databricks.com/packages/8c/d7/8ff98376b1acc4503253b685ea09981697385ce344d4e3935c2af49e044d/pfzy-0.3.4-py3-none-any.whl", hash = "sha256:5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96", size = 8537, upload-time = "2022-01-28T02:26:16.047Z" }, +] + [[package]] name = "pillow" version = "12.2.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, @@ -1991,7 +2025,7 @@ wheels = [ [[package]] name = "pluggy" version = "1.6.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, @@ -2000,7 +2034,7 @@ wheels = [ [[package]] name = "prettytable" version = "3.17.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "wcwidth" }, ] @@ -2012,7 +2046,7 @@ wheels = [ [[package]] name = "prompt-toolkit" version = "3.0.52" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "wcwidth" }, ] @@ -2024,7 +2058,7 @@ wheels = [ [[package]] name = "propcache" version = "0.5.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, @@ -2118,7 +2152,7 @@ wheels = [ [[package]] name = "proto-plus" version = "1.28.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "protobuf" }, ] @@ -2130,7 +2164,7 @@ wheels = [ [[package]] name = "protobuf" version = "6.33.6" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, @@ -2145,7 +2179,7 @@ wheels = [ [[package]] name = "pyarrow" version = "23.0.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", size = 34214575, upload-time = "2026-02-16T10:09:56.225Z" }, @@ -2188,7 +2222,7 @@ wheels = [ [[package]] name = "pyasn1" version = "0.6.3" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, @@ -2197,7 +2231,7 @@ wheels = [ [[package]] name = "pyasn1-modules" version = "0.4.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "pyasn1" }, ] @@ -2209,7 +2243,7 @@ wheels = [ [[package]] name = "pybreaker" version = "1.4.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/f2/89/fbf98e383f1ec6d117af2cd983efdb3eb7018b63834c427025764194cac2/pybreaker-1.4.1.tar.gz", hash = "sha256:8df2d245c73ba40c8242c56ffb4f12138fbadc23e296224740c2028ea9dc1178", size = 15555, upload-time = "2025-09-21T15:12:04.499Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/44/75/e64d3d40a741e2be21d69154f4e5c43a66f0c603c5ef11f49e01429a5932/pybreaker-1.4.1-py3-none-any.whl", hash = "sha256:b4dab4a05195b7f2a64a6c1a6c4ba7a96534ef56ea7210e6bcb59f28897160e0", size = 12915, upload-time = "2025-09-21T15:12:02.284Z" }, @@ -2218,7 +2252,7 @@ wheels = [ [[package]] name = "pycparser" version = "3.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, @@ -2227,7 +2261,7 @@ wheels = [ [[package]] name = "pydantic" version = "2.13.4" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, @@ -2242,7 +2276,7 @@ wheels = [ [[package]] name = "pydantic-core" version = "2.46.4" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "typing-extensions" }, ] @@ -2317,7 +2351,7 @@ wheels = [ [[package]] name = "pydantic-settings" version = "2.14.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, @@ -2331,7 +2365,7 @@ wheels = [ [[package]] name = "pygments" version = "2.20.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, @@ -2340,7 +2374,7 @@ wheels = [ [[package]] name = "pyjwt" version = "2.12.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, @@ -2354,7 +2388,7 @@ crypto = [ [[package]] name = "pyparsing" version = "3.3.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, @@ -2363,7 +2397,7 @@ wheels = [ [[package]] name = "pytest" version = "9.0.3" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, @@ -2379,7 +2413,7 @@ wheels = [ [[package]] name = "python-dateutil" version = "2.9.0.post0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "six" }, ] @@ -2391,7 +2425,7 @@ wheels = [ [[package]] name = "python-dotenv" version = "1.2.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, @@ -2400,7 +2434,7 @@ wheels = [ [[package]] name = "python-multipart" version = "0.0.32" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, @@ -2409,7 +2443,7 @@ wheels = [ [[package]] name = "pytz" version = "2026.1.post1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, @@ -2418,7 +2452,7 @@ wheels = [ [[package]] name = "pywin32" version = "311" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, { url = "https://pypi-proxy.cloud.databricks.com/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, @@ -2434,7 +2468,7 @@ wheels = [ [[package]] name = "pyyaml" version = "6.0.3" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, @@ -2480,7 +2514,7 @@ wheels = [ [[package]] name = "questionary" version = "2.1.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "prompt-toolkit" }, ] @@ -2492,7 +2526,7 @@ wheels = [ [[package]] name = "referencing" version = "0.37.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, @@ -2506,7 +2540,7 @@ wheels = [ [[package]] name = "regex" version = "2026.5.9" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" }, @@ -2594,7 +2628,7 @@ wheels = [ [[package]] name = "requests" version = "2.33.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, @@ -2609,7 +2643,7 @@ wheels = [ [[package]] name = "rich" version = "15.0.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, @@ -2622,7 +2656,7 @@ wheels = [ [[package]] name = "rpds-py" version = "2026.6.3" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, @@ -2718,7 +2752,7 @@ wheels = [ [[package]] name = "ruff" version = "0.15.12" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, @@ -2743,7 +2777,7 @@ wheels = [ [[package]] name = "s3transfer" version = "0.17.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "botocore" }, ] @@ -2755,7 +2789,7 @@ wheels = [ [[package]] name = "scikit-learn" version = "1.8.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "joblib" }, { name = "numpy" }, @@ -2799,7 +2833,7 @@ wheels = [ [[package]] name = "scipy" version = "1.17.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "numpy" }, ] @@ -2860,7 +2894,7 @@ wheels = [ [[package]] name = "shellingham" version = "1.5.4" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, @@ -2869,7 +2903,7 @@ wheels = [ [[package]] name = "six" version = "1.17.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, @@ -2878,7 +2912,7 @@ wheels = [ [[package]] name = "skops" version = "0.14.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "numpy" }, { name = "packaging" }, @@ -2894,7 +2928,7 @@ wheels = [ [[package]] name = "smmap" version = "5.0.3" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, @@ -2903,7 +2937,7 @@ wheels = [ [[package]] name = "sqlalchemy" version = "2.0.50" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, @@ -2944,7 +2978,7 @@ wheels = [ [[package]] name = "sqlparse" version = "0.5.5" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, @@ -2953,7 +2987,7 @@ wheels = [ [[package]] name = "sse-starlette" version = "3.4.5" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "anyio" }, { name = "starlette" }, @@ -2966,7 +3000,7 @@ wheels = [ [[package]] name = "starlette" version = "0.52.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, @@ -2979,7 +3013,7 @@ wheels = [ [[package]] name = "tenacity" version = "9.1.4" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, @@ -2988,7 +3022,7 @@ wheels = [ [[package]] name = "threadpoolctl" version = "3.6.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, @@ -2997,7 +3031,7 @@ wheels = [ [[package]] name = "thrift" version = "0.20.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "six" }, ] @@ -3006,7 +3040,7 @@ sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/3c/2d/8946864f [[package]] name = "tiktoken" version = "0.13.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "regex" }, { name = "requests" }, @@ -3053,7 +3087,7 @@ wheels = [ [[package]] name = "tomlkit" version = "0.14.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, @@ -3062,7 +3096,7 @@ wheels = [ [[package]] name = "tqdm" version = "4.67.3" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] @@ -3074,7 +3108,7 @@ wheels = [ [[package]] name = "ty" version = "0.0.33" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/84/44/9478c50c266826c1bf30d1692e589755bffa8f1c0a3eb7af8a346c255991/ty-0.0.33.tar.gz", hash = "sha256:46d63bda07403322cb6c28ccfdd5536be916e13df725c29f7ccd0a21f06bd9e8", size = 5559373, upload-time = "2026-04-28T10:45:13.18Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/e9/24/e287388c63a19191be26b32ff4dbd06029834068150ebe2532939bc4c851/ty-0.0.33-py3-none-linux_armv6l.whl", hash = "sha256:94d0a9d2234261a8911396d59e506b5923fe0971dbda43b9dcea287936887fcc", size = 11021308, upload-time = "2026-04-28T10:45:43.34Z" }, @@ -3098,7 +3132,7 @@ wheels = [ [[package]] name = "typer" version = "0.24.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "annotated-doc" }, { name = "click" }, @@ -3113,7 +3147,7 @@ wheels = [ [[package]] name = "typing-extensions" version = "4.15.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, @@ -3122,7 +3156,7 @@ wheels = [ [[package]] name = "typing-inspect" version = "0.9.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "mypy-extensions" }, { name = "typing-extensions" }, @@ -3135,7 +3169,7 @@ wheels = [ [[package]] name = "typing-inspection" version = "0.4.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "typing-extensions" }, ] @@ -3147,7 +3181,7 @@ wheels = [ [[package]] name = "tzdata" version = "2026.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, @@ -3159,6 +3193,7 @@ source = { editable = "." } dependencies = [ { name = "databricks-sql-connector" }, { name = "httpx" }, + { name = "inquirerpy" }, { name = "mcp" }, { name = "questionary" }, { name = "tomlkit" }, @@ -3181,6 +3216,7 @@ dev = [ requires-dist = [ { name = "databricks-sql-connector", specifier = ">=3.6.0" }, { name = "httpx", specifier = ">=0.27.1" }, + { name = "inquirerpy", specifier = ">=0.3.4" }, { name = "mcp", specifier = ">=1.28.0" }, { name = "mlflow", extras = ["databricks"], marker = "extra == 'tracing'", specifier = ">=3.4" }, { name = "questionary", specifier = ">=2.0.0" }, @@ -3199,7 +3235,7 @@ dev = [ [[package]] name = "urllib3" version = "2.6.3" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, @@ -3208,7 +3244,7 @@ wheels = [ [[package]] name = "uvicorn" version = "0.48.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "click" }, { name = "h11" }, @@ -3221,7 +3257,7 @@ wheels = [ [[package]] name = "waitress" version = "3.0.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" }, @@ -3230,7 +3266,7 @@ wheels = [ [[package]] name = "wcwidth" version = "0.6.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, @@ -3239,7 +3275,7 @@ wheels = [ [[package]] name = "werkzeug" version = "3.1.8" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "markupsafe" }, ] @@ -3251,7 +3287,7 @@ wheels = [ [[package]] name = "whenever" version = "0.7.3" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] @@ -3290,7 +3326,7 @@ wheels = [ [[package]] name = "yarl" version = "1.24.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } dependencies = [ { name = "idna" }, { name = "multidict" }, @@ -3372,7 +3408,7 @@ wheels = [ [[package]] name = "zipp" version = "4.1.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple" } +source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } sdist = { url = "https://pypi-proxy.cloud.databricks.com/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } wheels = [ { url = "https://pypi-proxy.cloud.databricks.com/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" },